From 441bca5bdaa2f9afcd970e7cb1aa0b75ade32c1f Mon Sep 17 00:00:00 2001 From: Simone Chemelli Date: Wed, 30 Apr 2025 12:26:20 +0300 Subject: [PATCH 01/37] Use CONF_PIN in SamsungTv config flow (#143621) * Use CONF_PIN in SamsunTv config flow * Adjust tests --------- Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> --- homeassistant/components/samsungtv/config_flow.py | 9 +++++---- tests/components/samsungtv/test_config_flow.py | 13 +++++++------ 2 files changed, 12 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/samsungtv/config_flow.py b/homeassistant/components/samsungtv/config_flow.py index 3f34520e87ade4..74915c9251b4ad 100644 --- a/homeassistant/components/samsungtv/config_flow.py +++ b/homeassistant/components/samsungtv/config_flow.py @@ -24,6 +24,7 @@ CONF_METHOD, CONF_MODEL, CONF_NAME, + CONF_PIN, CONF_PORT, CONF_TOKEN, ) @@ -314,7 +315,7 @@ async def async_step_encrypted_pairing( if user_input is not None: if ( - (pin := user_input.get("pin")) + (pin := user_input.get(CONF_PIN)) and (token := await self._authenticator.try_pin(pin)) and (session_id := await self._authenticator.get_session_id_and_close()) ): @@ -333,7 +334,7 @@ async def async_step_encrypted_pairing( step_id="encrypted_pairing", errors=errors, description_placeholders={"device": self._title}, - data_schema=vol.Schema({vol.Required("pin"): str}), + data_schema=vol.Schema({vol.Required(CONF_PIN): str}), ) @callback @@ -596,7 +597,7 @@ async def async_step_reauth_confirm_encrypted( if user_input is not None: if ( - (pin := user_input.get("pin")) + (pin := user_input.get(CONF_PIN)) and (token := await self._authenticator.try_pin(pin)) and (session_id := await self._authenticator.get_session_id_and_close()) ): @@ -615,5 +616,5 @@ async def async_step_reauth_confirm_encrypted( step_id="reauth_confirm_encrypted", errors=errors, description_placeholders={"device": self._title}, - data_schema=vol.Schema({vol.Required("pin"): str}), + data_schema=vol.Schema({vol.Required(CONF_PIN): str}), ) diff --git a/tests/components/samsungtv/test_config_flow.py b/tests/components/samsungtv/test_config_flow.py index 576a5f6d53490b..cf9390241d5f91 100644 --- a/tests/components/samsungtv/test_config_flow.py +++ b/tests/components/samsungtv/test_config_flow.py @@ -41,6 +41,7 @@ CONF_METHOD, CONF_MODEL, CONF_NAME, + CONF_PIN, CONF_PORT, CONF_TOKEN, ) @@ -324,13 +325,13 @@ async def test_user_encrypted_websocket( assert result2["step_id"] == "encrypted_pairing" result3 = await hass.config_entries.flow.async_configure( - result2["flow_id"], user_input={"pin": "invalid"} + result2["flow_id"], user_input={CONF_PIN: "invalid"} ) assert result3["step_id"] == "encrypted_pairing" assert result3["errors"] == {"base": "invalid_pin"} result4 = await hass.config_entries.flow.async_configure( - result3["flow_id"], user_input={"pin": "1234"} + result3["flow_id"], user_input={CONF_PIN: "1234"} ) assert result4["type"] is FlowResultType.CREATE_ENTRY @@ -728,13 +729,13 @@ async def test_ssdp_encrypted_websocket_success_populates_mac_address_and_ssdp_l assert result2["step_id"] == "encrypted_pairing" result3 = await hass.config_entries.flow.async_configure( - result2["flow_id"], user_input={"pin": "invalid"} + result2["flow_id"], user_input={CONF_PIN: "invalid"} ) assert result3["step_id"] == "encrypted_pairing" assert result3["errors"] == {"base": "invalid_pin"} result4 = await hass.config_entries.flow.async_configure( - result3["flow_id"], user_input={"pin": "1234"} + result3["flow_id"], user_input={CONF_PIN: "1234"} ) assert result4["type"] is FlowResultType.CREATE_ENTRY @@ -1947,14 +1948,14 @@ async def test_form_reauth_encrypted(hass: HomeAssistant) -> None: # Invalid PIN result = await hass.config_entries.flow.async_configure( - result["flow_id"], user_input={"pin": "invalid"} + result["flow_id"], user_input={CONF_PIN: "invalid"} ) assert result["type"] is FlowResultType.FORM assert result["step_id"] == "reauth_confirm_encrypted" # Valid PIN result = await hass.config_entries.flow.async_configure( - result["flow_id"], user_input={"pin": "1234"} + result["flow_id"], user_input={CONF_PIN: "1234"} ) await hass.async_block_till_done() assert result["type"] is FlowResultType.ABORT From ef023f084b06b6a841b0ecbcb8f93e8acbe24a94 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 30 Apr 2025 11:47:28 +0200 Subject: [PATCH 02/37] Ensure port is stored and used in SamsungTV legacy bridge (#143940) * Ensure port is stored and used in SamsungTV legacy bridge * Tweak --- homeassistant/components/samsungtv/bridge.py | 8 ++++---- tests/components/samsungtv/test_config_flow.py | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/samsungtv/bridge.py b/homeassistant/components/samsungtv/bridge.py index 3bf052fa9d88df..8bb9869f409d54 100644 --- a/homeassistant/components/samsungtv/bridge.py +++ b/homeassistant/components/samsungtv/bridge.py @@ -150,7 +150,7 @@ def get_bridge( ) -> SamsungTVBridge: """Get Bridge instance.""" if method == METHOD_LEGACY or port == LEGACY_PORT: - return SamsungTVLegacyBridge(hass, method, host, port) + return SamsungTVLegacyBridge(hass, method, host, port or LEGACY_PORT) if method == METHOD_ENCRYPTED_WEBSOCKET or port == ENCRYPTED_WEBSOCKET_PORT: return SamsungTVEncryptedBridge(hass, method, host, port, entry_data) return SamsungTVWSBridge(hass, method, host, port, entry_data) @@ -262,14 +262,14 @@ def __init__( self, hass: HomeAssistant, method: str, host: str, port: int | None ) -> None: """Initialize Bridge.""" - super().__init__(hass, method, host, LEGACY_PORT) + super().__init__(hass, method, host, port) self.config = { CONF_NAME: VALUE_CONF_NAME, CONF_DESCRIPTION: VALUE_CONF_NAME, CONF_ID: VALUE_CONF_ID, CONF_HOST: host, CONF_METHOD: method, - CONF_PORT: None, + CONF_PORT: port, CONF_TIMEOUT: 1, } self._remote: Remote | None = None @@ -301,7 +301,7 @@ def _try_connect(self) -> str: CONF_ID: VALUE_CONF_ID, CONF_HOST: self.host, CONF_METHOD: self.method, - CONF_PORT: None, + CONF_PORT: self.port, # We need this high timeout because waiting for auth popup # is just an open socket CONF_TIMEOUT: TIMEOUT_REQUEST, diff --git a/tests/components/samsungtv/test_config_flow.py b/tests/components/samsungtv/test_config_flow.py index cf9390241d5f91..5ff259c2120ad3 100644 --- a/tests/components/samsungtv/test_config_flow.py +++ b/tests/components/samsungtv/test_config_flow.py @@ -173,7 +173,7 @@ "description": "HomeAssistant", "id": "ha.component.samsung", "method": "legacy", - "port": None, + "port": LEGACY_PORT, "host": "fake_host", "timeout": TIMEOUT_REQUEST, } From 4ac29c6aef15c5057c5bb786b3321761ff44fbac Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 30 Apr 2025 11:47:39 +0200 Subject: [PATCH 03/37] Remove redundant turn_on/turn_off methods in samsungtv (#143939) --- homeassistant/components/samsungtv/entity.py | 6 ++++-- homeassistant/components/samsungtv/media_player.py | 8 -------- homeassistant/components/samsungtv/remote.py | 8 -------- 3 files changed, 4 insertions(+), 18 deletions(-) diff --git a/homeassistant/components/samsungtv/entity.py b/homeassistant/components/samsungtv/entity.py index f3ecee373e3cd5..2126dae82f4b63 100644 --- a/homeassistant/components/samsungtv/entity.py +++ b/homeassistant/components/samsungtv/entity.py @@ -2,6 +2,8 @@ from __future__ import annotations +from typing import Any + from wakeonlan import send_magic_packet from homeassistant.const import ( @@ -82,12 +84,12 @@ def _wake_on_lan(self) -> None: # broadcast a packet as well send_magic_packet(self._mac) - async def _async_turn_off(self) -> None: + async def async_turn_off(self, **kwargs: Any) -> None: """Turn the device off.""" await self._bridge.async_power_off() await self.coordinator.async_refresh() - async def _async_turn_on(self) -> None: + async def async_turn_on(self, **kwargs: Any) -> None: """Turn the remote on.""" if self._turn_on_action: LOGGER.debug("Attempting to turn on %s via automation", self.entity_id) diff --git a/homeassistant/components/samsungtv/media_player.py b/homeassistant/components/samsungtv/media_player.py index 1c475ee6c25165..5a48159b717f7b 100644 --- a/homeassistant/components/samsungtv/media_player.py +++ b/homeassistant/components/samsungtv/media_player.py @@ -299,10 +299,6 @@ async def _async_send_keys(self, keys: list[str]) -> None: return await self._bridge.async_send_keys(keys) - async def async_turn_off(self) -> None: - """Turn off media player.""" - await super()._async_turn_off() - async def async_set_volume_level(self, volume: float) -> None: """Set volume level on the media player.""" if (dmr_device := self._dmr_device) is None: @@ -373,10 +369,6 @@ async def async_play_media( keys=[f"KEY_{digit}" for digit in media_id] + ["KEY_ENTER"] ) - async def async_turn_on(self) -> None: - """Turn the media player on.""" - await super()._async_turn_on() - async def async_select_source(self, source: str) -> None: """Select input source.""" if self._app_list and source in self._app_list: diff --git a/homeassistant/components/samsungtv/remote.py b/homeassistant/components/samsungtv/remote.py index 2c6b46c8bb2f1d..ec2e8c45963132 100644 --- a/homeassistant/components/samsungtv/remote.py +++ b/homeassistant/components/samsungtv/remote.py @@ -38,10 +38,6 @@ def _handle_coordinator_update(self) -> None: self._attr_is_on = self.coordinator.is_on self.async_write_ha_state() - async def async_turn_off(self, **kwargs: Any) -> None: - """Turn the device off.""" - await super()._async_turn_off() - async def async_send_command(self, command: Iterable[str], **kwargs: Any) -> None: """Send a command to a device. @@ -57,7 +53,3 @@ async def async_send_command(self, command: Iterable[str], **kwargs: Any) -> Non for _ in range(num_repeats): await self._bridge.async_send_keys(command_list) - - async def async_turn_on(self, **kwargs: Any) -> None: - """Turn the remote on.""" - await super()._async_turn_on() From a7af0eaccd6a171934fd61877640480c5b045584 Mon Sep 17 00:00:00 2001 From: Petar Petrov Date: Wed, 30 Apr 2025 12:54:50 +0300 Subject: [PATCH 04/37] Add retry restore step to ZWave-JS migration (#143934) * Add retry restore step to ZWave-JS migration * improve test --- .../components/zwave_js/config_flow.py | 10 +++++++- .../components/zwave_js/strings.json | 6 ++++- tests/components/zwave_js/test_config_flow.py | 23 +++++++++++++++++-- 3 files changed, 35 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/zwave_js/config_flow.py b/homeassistant/components/zwave_js/config_flow.py index eefb673f1c7fd0..2d9bc0fa1cd1fa 100644 --- a/homeassistant/components/zwave_js/config_flow.py +++ b/homeassistant/components/zwave_js/config_flow.py @@ -1133,7 +1133,15 @@ async def async_step_restore_failed( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: """Restore failed.""" - return self.async_abort(reason="restore_failed") + if user_input is not None: + return await self.async_step_restore_nvm() + + return self.async_show_form( + step_id="restore_failed", + description_placeholders={ + "file_path": str(self.backup_filepath), + }, + ) async def async_step_migration_done( self, user_input: dict[str, Any] | None = None diff --git a/homeassistant/components/zwave_js/strings.json b/homeassistant/components/zwave_js/strings.json index 9c704e675a3e4e..53615e846915b8 100644 --- a/homeassistant/components/zwave_js/strings.json +++ b/homeassistant/components/zwave_js/strings.json @@ -21,7 +21,6 @@ "not_zwave_js_addon": "Discovered add-on is not the official Z-Wave add-on.", "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]", "reset_failed": "Failed to reset controller.", - "restore_failed": "Failed to restore network.", "usb_ports_failed": "Failed to get USB devices." }, "error": { @@ -118,6 +117,11 @@ "title": "Unplug your old controller", "description": "Backup saved to \"{file_path}\"\n\nYour old controller has been reset. If the hardware is no longer needed, you can now unplug it.\n\nPlease make sure your new controller is plugged in before continuing." }, + "restore_failed": { + "title": "Restoring unsuccessful", + "description": "Your Z-Wave network could not be restored to the new controller. This means that your Z-Wave devices are not connected to Home Assistant.\n\nThe backup is saved to ”{file_path}”", + "submit": "Try again" + }, "choose_serial_port": { "data": { "usb_path": "[%key:common::config_flow::data::usb_path%]" diff --git a/tests/components/zwave_js/test_config_flow.py b/tests/components/zwave_js/test_config_flow.py index a4c80fb4df99d1..8256e10e6975b7 100644 --- a/tests/components/zwave_js/test_config_flow.py +++ b/tests/components/zwave_js/test_config_flow.py @@ -3914,8 +3914,27 @@ async def mock_backup_nvm_raw(): result = await hass.config_entries.flow.async_configure(result["flow_id"]) - assert result["type"] == FlowResultType.ABORT - assert result["reason"] == "restore_failed" + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "restore_failed" + assert result["description_placeholders"]["file_path"] + + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + + assert result["type"] == FlowResultType.SHOW_PROGRESS + assert result["step_id"] == "restore_nvm" + + await hass.async_block_till_done() + + assert client.driver.controller.async_restore_nvm.call_count == 2 + + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "restore_failed" + + hass.config_entries.flow.async_abort(result["flow_id"]) + + assert len(hass.config_entries.flow.async_progress()) == 0 async def test_get_driver_failure(hass: HomeAssistant, integration, client) -> None: From 40217e764da77e90b1bc3c5befe13213d717db5c Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 30 Apr 2025 12:14:28 +0200 Subject: [PATCH 05/37] Allow overriding blueprinted templates (#143874) * Allow overriding blueprinted templates * Remove duplicated line --- homeassistant/components/template/config.py | 13 +-- tests/components/template/test_blueprint.py | 95 +++++++++++++++++++++ 2 files changed, 98 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/template/config.py b/homeassistant/components/template/config.py index 9d0cf148f3f392..ca643653cecb54 100644 --- a/homeassistant/components/template/config.py +++ b/homeassistant/components/template/config.py @@ -9,7 +9,6 @@ from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR_DOMAIN from homeassistant.components.blueprint import ( - BLUEPRINT_INSTANCE_FIELDS, is_blueprint_instance_config, schemas as blueprint_schemas, ) @@ -141,13 +140,6 @@ def _backward_compat_schema(value: Any | None) -> Any: _backward_compat_schema, blueprint_schemas.BLUEPRINT_SCHEMA ) -TEMPLATE_BLUEPRINT_INSTANCE_SCHEMA = vol.Schema( - { - vol.Optional(CONF_NAME): cv.string, - vol.Optional(CONF_UNIQUE_ID): cv.string, - } -).extend(BLUEPRINT_INSTANCE_FIELDS.schema) - async def _async_resolve_blueprints( hass: HomeAssistant, @@ -161,10 +153,11 @@ async def _async_resolve_blueprints( raw_config = dict(config) if is_blueprint_instance_config(config): - config = TEMPLATE_BLUEPRINT_INSTANCE_SCHEMA(config) blueprints = async_get_blueprints(hass) - blueprint_inputs = await blueprints.async_inputs_from_config(config) + blueprint_inputs = await blueprints.async_inputs_from_config( + _backward_compat_schema(config) + ) raw_blueprint_inputs = blueprint_inputs.config_with_inputs config = blueprint_inputs.async_substitute() diff --git a/tests/components/template/test_blueprint.py b/tests/components/template/test_blueprint.py index 43f2c310289e29..312c04b670c80b 100644 --- a/tests/components/template/test_blueprint.py +++ b/tests/components/template/test_blueprint.py @@ -272,6 +272,101 @@ async def test_trigger_event_sensor( await template.async_get_blueprints(hass).async_remove_blueprint(blueprint) +@pytest.mark.parametrize( + ("blueprint", "override"), + [ + # Override a blueprint with modern schema with legacy schema + ( + "test_event_sensor.yaml", + {"trigger": {"platform": "event", "event_type": "override"}}, + ), + # Override a blueprint with modern schema with modern schema + ( + "test_event_sensor.yaml", + {"triggers": {"platform": "event", "event_type": "override"}}, + ), + # Override a blueprint with legacy schema with legacy schema + ( + "test_event_sensor_legacy_schema.yaml", + {"trigger": {"platform": "event", "event_type": "override"}}, + ), + # Override a blueprint with legacy schema with modern schema + ( + "test_event_sensor_legacy_schema.yaml", + {"triggers": {"platform": "event", "event_type": "override"}}, + ), + ], +) +async def test_blueprint_template_override( + hass: HomeAssistant, blueprint: str, override: dict +) -> None: + """Test blueprint template where the template config overrides the blueprint.""" + assert await async_setup_component( + hass, + "template", + { + "template": [ + { + "use_blueprint": { + "path": blueprint, + "input": { + "event_type": "my_custom_event", + "event_data": {"foo": "bar"}, + }, + }, + "name": "My Custom Event", + } + | override, + ] + }, + ) + await hass.async_block_till_done() + + date_state = hass.states.get("sensor.my_custom_event") + assert date_state is not None + assert date_state.state == "unknown" + + context = Context() + now = dt_util.utcnow() + with patch("homeassistant.util.dt.now", return_value=now): + hass.bus.async_fire( + "my_custom_event", {"foo": "bar", "beer": 2}, context=context + ) + await hass.async_block_till_done() + + date_state = hass.states.get("sensor.my_custom_event") + assert date_state is not None + assert date_state.state == "unknown" + + context = Context() + now = dt_util.utcnow() + with patch("homeassistant.util.dt.now", return_value=now): + hass.bus.async_fire("override", {"foo": "bar", "beer": 2}, context=context) + await hass.async_block_till_done() + + date_state = hass.states.get("sensor.my_custom_event") + assert date_state is not None + assert date_state.state == now.isoformat(timespec="seconds") + data = date_state.attributes.get("data") + assert data is not None + assert data != "" + assert data.get("foo") == "bar" + assert data.get("beer") == 2 + + inverted_foo_template = template.helpers.blueprint_in_template( + hass, "sensor.my_custom_event" + ) + assert inverted_foo_template == blueprint + + inverted_binary_sensor_blueprint_entity_ids = ( + template.helpers.templates_with_blueprint(hass, blueprint) + ) + assert len(inverted_binary_sensor_blueprint_entity_ids) == 1 + + with pytest.raises(BlueprintInUse): + await template.async_get_blueprints(hass).async_remove_blueprint(blueprint) + + async def test_domain_blueprint(hass: HomeAssistant) -> None: """Test DomainBlueprint services.""" reload_handler_calls = async_mock_service(hass, DOMAIN, SERVICE_RELOAD) From 73a1dbffebf09da4b166a42136b9f40676bb0443 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 30 Apr 2025 12:34:36 +0200 Subject: [PATCH 06/37] Fix invalid-else in samsungtv (#143942) --- homeassistant/components/samsungtv/bridge.py | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/samsungtv/bridge.py b/homeassistant/components/samsungtv/bridge.py index 8bb9869f409d54..e782b1dfcd9670 100644 --- a/homeassistant/components/samsungtv/bridge.py +++ b/homeassistant/components/samsungtv/bridge.py @@ -510,6 +510,7 @@ async def async_is_on(self) -> bool: async def async_try_connect(self) -> str: """Try to connect to the Websocket TV.""" + temp_result = None for self.port in WEBSOCKET_PORTS: config = { CONF_NAME: VALUE_CONF_NAME, @@ -521,7 +522,6 @@ async def async_try_connect(self) -> str: CONF_TIMEOUT: TIMEOUT_REQUEST, } - result = None try: LOGGER.debug("Try config: %s", config) async with SamsungTVWSAsyncRemote( @@ -545,22 +545,19 @@ async def async_try_connect(self) -> str: config, err, ) - result = RESULT_NOT_SUPPORTED + temp_result = RESULT_NOT_SUPPORTED except WebSocketException as err: LOGGER.debug( "Working but unsupported config: %s, error: %s", config, err ) - result = RESULT_NOT_SUPPORTED + temp_result = RESULT_NOT_SUPPORTED except UnauthorizedError as err: LOGGER.debug("Failing config: %s, %s error: %s", config, type(err), err) return RESULT_AUTH_MISSING except (ConnectionFailure, OSError, AsyncioTimeoutError) as err: LOGGER.debug("Failing config: %s, %s error: %s", config, type(err), err) - else: # noqa: PLW0120 - if result: - return result - return RESULT_CANNOT_CONNECT + return temp_result or RESULT_CANNOT_CONNECT async def async_device_info(self, force: bool = False) -> dict[str, Any] | None: """Try to gather infos of this TV.""" From 6c633668f668aafe02e0876eedc195eeabb74688 Mon Sep 17 00:00:00 2001 From: Pete Sage <76050312+PeteRager@users.noreply.github.com> Date: Wed, 30 Apr 2025 06:44:16 -0400 Subject: [PATCH 07/37] Add Rehlko (formerly Kohler Energy Management) Integration (#143602) Co-authored-by: Joost Lekkerkerker Co-authored-by: J. Nick Koston --- CODEOWNERS | 2 + homeassistant/components/rehlko/__init__.py | 95 ++ .../components/rehlko/config_flow.py | 103 ++ homeassistant/components/rehlko/const.py | 25 + .../components/rehlko/coordinator.py | 78 ++ homeassistant/components/rehlko/entity.py | 81 ++ homeassistant/components/rehlko/icons.json | 18 + homeassistant/components/rehlko/manifest.json | 17 + .../components/rehlko/quality_scale.yaml | 78 ++ homeassistant/components/rehlko/sensor.py | 203 ++++ homeassistant/components/rehlko/strings.json | 99 ++ homeassistant/generated/config_flows.py | 1 + homeassistant/generated/dhcp.py | 5 + homeassistant/generated/integrations.json | 6 + requirements_all.txt | 3 + requirements_test_all.txt | 3 + tests/components/rehlko/__init__.py | 1 + tests/components/rehlko/conftest.py | 100 ++ .../components/rehlko/fixtures/generator.json | 191 ++++ tests/components/rehlko/fixtures/homes.json | 82 ++ .../rehlko/snapshots/test_sensor.ambr | 876 ++++++++++++++++++ tests/components/rehlko/test_config_flow.py | 218 +++++ tests/components/rehlko/test_sensor.py | 85 ++ 23 files changed, 2370 insertions(+) create mode 100644 homeassistant/components/rehlko/__init__.py create mode 100644 homeassistant/components/rehlko/config_flow.py create mode 100644 homeassistant/components/rehlko/const.py create mode 100644 homeassistant/components/rehlko/coordinator.py create mode 100644 homeassistant/components/rehlko/entity.py create mode 100644 homeassistant/components/rehlko/icons.json create mode 100644 homeassistant/components/rehlko/manifest.json create mode 100644 homeassistant/components/rehlko/quality_scale.yaml create mode 100644 homeassistant/components/rehlko/sensor.py create mode 100644 homeassistant/components/rehlko/strings.json create mode 100644 tests/components/rehlko/__init__.py create mode 100644 tests/components/rehlko/conftest.py create mode 100644 tests/components/rehlko/fixtures/generator.json create mode 100644 tests/components/rehlko/fixtures/homes.json create mode 100644 tests/components/rehlko/snapshots/test_sensor.ambr create mode 100644 tests/components/rehlko/test_config_flow.py create mode 100644 tests/components/rehlko/test_sensor.py diff --git a/CODEOWNERS b/CODEOWNERS index 31057488869c67..1574f8ee826451 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1260,6 +1260,8 @@ build.json @home-assistant/supervisor /tests/components/recovery_mode/ @home-assistant/core /homeassistant/components/refoss/ @ashionky /tests/components/refoss/ @ashionky +/homeassistant/components/rehlko/ @bdraco @peterager +/tests/components/rehlko/ @bdraco @peterager /homeassistant/components/remote/ @home-assistant/core /tests/components/remote/ @home-assistant/core /homeassistant/components/remote_calendar/ @Thomas55555 diff --git a/homeassistant/components/rehlko/__init__.py b/homeassistant/components/rehlko/__init__.py new file mode 100644 index 00000000000000..19702527259f71 --- /dev/null +++ b/homeassistant/components/rehlko/__init__.py @@ -0,0 +1,95 @@ +"""The Rehlko integration.""" + +from __future__ import annotations + +import logging + +from aiokem import AioKem, AuthenticationError + +from homeassistant.const import CONF_EMAIL, CONF_PASSWORD, Platform +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady +from homeassistant.helpers.aiohttp_client import async_get_clientsession + +from .const import ( + CONF_REFRESH_TOKEN, + CONNECTION_EXCEPTIONS, + DEVICE_DATA_DEVICES, + DEVICE_DATA_DISPLAY_NAME, + DEVICE_DATA_ID, + DOMAIN, +) +from .coordinator import RehlkoConfigEntry, RehlkoRuntimeData, RehlkoUpdateCoordinator + +PLATFORMS = [Platform.SENSOR] +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry(hass: HomeAssistant, entry: RehlkoConfigEntry) -> bool: + """Set up Rehlko from a config entry.""" + websession = async_get_clientsession(hass) + rehlko = AioKem(session=websession) + + async def async_refresh_token_update(refresh_token: str) -> None: + """Handle refresh token update.""" + _LOGGER.debug("Saving refresh token") + # Update the config entry with the new refresh token + hass.config_entries.async_update_entry( + entry, + data={**entry.data, CONF_REFRESH_TOKEN: refresh_token}, + ) + + rehlko.set_refresh_token_callback(async_refresh_token_update) + rehlko.set_retry_policy(retry_count=3, retry_delays=[5, 10, 20]) + + try: + await rehlko.authenticate( + entry.data[CONF_EMAIL], + entry.data[CONF_PASSWORD], + entry.data.get(CONF_REFRESH_TOKEN), + ) + except AuthenticationError as ex: + raise ConfigEntryAuthFailed( + translation_domain=DOMAIN, + translation_key="invalid_auth", + translation_placeholders={CONF_EMAIL: entry.data[CONF_EMAIL]}, + ) from ex + except CONNECTION_EXCEPTIONS as ex: + raise ConfigEntryNotReady( + translation_domain=DOMAIN, + translation_key="cannot_connect", + ) from ex + coordinators: dict[int, RehlkoUpdateCoordinator] = {} + homes = await rehlko.get_homes() + + entry.runtime_data = RehlkoRuntimeData( + coordinators=coordinators, + rehlko=rehlko, + homes=homes, + ) + + for home_data in homes: + for device_data in home_data[DEVICE_DATA_DEVICES]: + device_id = device_data[DEVICE_DATA_ID] + coordinator = RehlkoUpdateCoordinator( + hass=hass, + logger=_LOGGER, + config_entry=entry, + home_data=home_data, + device_id=device_id, + device_data=device_data, + rehlko=rehlko, + name=f"{DOMAIN} {device_data[DEVICE_DATA_DISPLAY_NAME]}", + ) + # Intentionally done in series to avoid overloading + # the Rehlko API with requests + await coordinator.async_config_entry_first_refresh() + coordinators[device_id] = coordinator + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: RehlkoConfigEntry) -> bool: + """Unload a config entry.""" + await entry.runtime_data.rehlko.close() + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/rehlko/config_flow.py b/homeassistant/components/rehlko/config_flow.py new file mode 100644 index 00000000000000..16f97bb385af46 --- /dev/null +++ b/homeassistant/components/rehlko/config_flow.py @@ -0,0 +1,103 @@ +"""Config flow for Rehlko integration.""" + +from __future__ import annotations + +from collections.abc import Mapping +import logging +from typing import Any + +from aiokem import AioKem, AuthenticationError +import voluptuous as vol + +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.const import CONF_EMAIL, CONF_PASSWORD +from homeassistant.helpers.aiohttp_client import async_get_clientsession + +from .const import CONNECTION_EXCEPTIONS, DOMAIN + +_LOGGER = logging.getLogger(__name__) + + +class RehlkoConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle a config flow for Rehlko.""" + + VERSION = 1 + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle the initial step.""" + errors: dict[str, str] = {} + + if user_input is not None: + errors, token_subject = await self._async_validate_or_error(user_input) + if not errors: + await self.async_set_unique_id(token_subject) + self._abort_if_unique_id_configured() + email: str = user_input[CONF_EMAIL] + normalized_email = email.lower() + return self.async_create_entry(title=normalized_email, data=user_input) + + return self.async_show_form( + step_id="user", + data_schema=vol.Schema( + { + vol.Required(CONF_EMAIL): str, + vol.Required(CONF_PASSWORD): str, + } + ), + errors=errors, + ) + + async def _async_validate_or_error( + self, config: dict[str, Any] + ) -> tuple[dict[str, str], str | None]: + """Validate the user input.""" + errors: dict[str, str] = {} + token_subject = None + rehlko = AioKem(session=async_get_clientsession(self.hass)) + try: + await rehlko.authenticate(config[CONF_EMAIL], config[CONF_PASSWORD]) + except CONNECTION_EXCEPTIONS: + errors["base"] = "cannot_connect" + except AuthenticationError: + errors[CONF_PASSWORD] = "invalid_auth" + except Exception: + _LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + else: + token_subject = rehlko.get_token_subject() + return errors, token_subject + + async def async_step_reauth( + self, entry_data: Mapping[str, Any] + ) -> ConfigFlowResult: + """Handle reauth.""" + return await self.async_step_reauth_confirm() + + async def async_step_reauth_confirm( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle reauth input.""" + errors: dict[str, str] = {} + reauth_entry = self._get_reauth_entry() + existing_data = reauth_entry.data + description_placeholders: dict[str, str] = { + CONF_EMAIL: existing_data[CONF_EMAIL] + } + if user_input is not None: + errors, _ = await self._async_validate_or_error( + {**existing_data, **user_input} + ) + if not errors: + return self.async_update_reload_and_abort( + reauth_entry, + data_updates=user_input, + ) + + return self.async_show_form( + description_placeholders=description_placeholders, + step_id="reauth_confirm", + data_schema=vol.Schema({vol.Required(CONF_PASSWORD): str}), + errors=errors, + ) diff --git a/homeassistant/components/rehlko/const.py b/homeassistant/components/rehlko/const.py new file mode 100644 index 00000000000000..f63c0872d46e6f --- /dev/null +++ b/homeassistant/components/rehlko/const.py @@ -0,0 +1,25 @@ +"""Constants for the Rehlko integration.""" + +from aiokem import CommunicationError + +DOMAIN = "rehlko" + +CONF_REFRESH_TOKEN = "refresh_token" + +DEVICE_DATA_DEVICES = "devices" +DEVICE_DATA_PRODUCT = "product" +DEVICE_DATA_FIRMWARE_VERSION = "firmwareVersion" +DEVICE_DATA_MODEL_NAME = "modelDisplayName" +DEVICE_DATA_ID = "id" +DEVICE_DATA_DISPLAY_NAME = "displayName" +DEVICE_DATA_MAC_ADDRESS = "macAddress" +DEVICE_DATA_IS_CONNECTED = "isConnected" + +KOHLER = "Kohler" + +GENERATOR_DATA_DEVICE = "device" + +CONNECTION_EXCEPTIONS = ( + TimeoutError, + CommunicationError, +) diff --git a/homeassistant/components/rehlko/coordinator.py b/homeassistant/components/rehlko/coordinator.py new file mode 100644 index 00000000000000..f5a268dff744d4 --- /dev/null +++ b/homeassistant/components/rehlko/coordinator.py @@ -0,0 +1,78 @@ +"""The Rehlko coordinator.""" + +from __future__ import annotations + +from dataclasses import dataclass +from datetime import timedelta +import logging +from typing import Any + +from aiokem import AioKem, CommunicationError + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + +type RehlkoConfigEntry = ConfigEntry[RehlkoRuntimeData] + +SCAN_INTERVAL_MINUTES = timedelta(minutes=10) + + +@dataclass +class RehlkoRuntimeData: + """Dataclass to hold runtime data for the Rehlko integration.""" + + coordinators: dict[int, RehlkoUpdateCoordinator] + rehlko: AioKem + homes: list[dict[str, Any]] + + +class RehlkoUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): + """Class to manage fetching Rehlko data API.""" + + config_entry: RehlkoConfigEntry + + def __init__( + self, + hass: HomeAssistant, + logger: logging.Logger, + config_entry: RehlkoConfigEntry, + rehlko: AioKem, + home_data: dict[str, Any], + device_data: dict[str, Any], + device_id: int, + name: str, + ) -> None: + """Initialize.""" + self.rehlko = rehlko + self.device_data = device_data + self.device_id = device_id + self.home_data = home_data + super().__init__( + hass=hass, + logger=logger, + config_entry=config_entry, + name=name, + update_interval=SCAN_INTERVAL_MINUTES, + ) + + async def _async_update_data(self) -> dict[str, Any]: + """Update data via library.""" + try: + result = await self.rehlko.get_generator_data(self.device_id) + except CommunicationError as error: + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key="update_failed", + ) from error + return result + + @property + def entry_unique_id(self) -> str: + """Get the unique ID for the entry.""" + assert self.config_entry.unique_id + return self.config_entry.unique_id diff --git a/homeassistant/components/rehlko/entity.py b/homeassistant/components/rehlko/entity.py new file mode 100644 index 00000000000000..94d384e1949dd8 --- /dev/null +++ b/homeassistant/components/rehlko/entity.py @@ -0,0 +1,81 @@ +"""Base class for Rehlko entities.""" + +from __future__ import annotations + +from typing import Any + +from homeassistant.helpers import device_registry as dr +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.entity import EntityDescription +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import ( + DEVICE_DATA_DISPLAY_NAME, + DEVICE_DATA_FIRMWARE_VERSION, + DEVICE_DATA_IS_CONNECTED, + DEVICE_DATA_MAC_ADDRESS, + DEVICE_DATA_MODEL_NAME, + DEVICE_DATA_PRODUCT, + DOMAIN, + GENERATOR_DATA_DEVICE, + KOHLER, +) +from .coordinator import RehlkoUpdateCoordinator + + +def _get_device_connections(mac_address: str) -> set[tuple[str, str]]: + """Get device connections.""" + try: + mac_address_hex = mac_address.replace(":", "") + except ValueError: # MacAddress may be invalid if the gateway is offline + return set() + return {(dr.CONNECTION_NETWORK_MAC, mac_address_hex)} + + +class RehlkoEntity(CoordinatorEntity[RehlkoUpdateCoordinator]): + """Representation of a Rehlko entity.""" + + _attr_has_entity_name = True + + def __init__( + self, + coordinator: RehlkoUpdateCoordinator, + device_id: int, + device_data: dict, + description: EntityDescription, + use_device_key: bool = False, + ) -> None: + """Initialize the sensor.""" + super().__init__(coordinator) + self.entity_description = description + self._device_id = device_id + self._attr_unique_id = ( + f"{coordinator.entry_unique_id}_{device_id}_{description.key}" + ) + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, f"{coordinator.entry_unique_id}_{device_id}")}, + name=device_data[DEVICE_DATA_DISPLAY_NAME], + hw_version=device_data[DEVICE_DATA_PRODUCT], + sw_version=device_data[DEVICE_DATA_FIRMWARE_VERSION], + model=device_data[DEVICE_DATA_MODEL_NAME], + manufacturer=KOHLER, + connections=_get_device_connections(device_data[DEVICE_DATA_MAC_ADDRESS]), + ) + self._use_device_key = use_device_key + + @property + def _device_data(self) -> dict[str, Any]: + """Return the device data.""" + return self.coordinator.data[GENERATOR_DATA_DEVICE] + + @property + def _rehlko_value(self) -> str: + """Return the sensor value.""" + if self._use_device_key: + return self._device_data[self.entity_description.key] + return self.coordinator.data[self.entity_description.key] + + @property + def available(self) -> bool: + """Return if entity is available.""" + return super().available and self._device_data[DEVICE_DATA_IS_CONNECTED] diff --git a/homeassistant/components/rehlko/icons.json b/homeassistant/components/rehlko/icons.json new file mode 100644 index 00000000000000..cb409eba14f4e3 --- /dev/null +++ b/homeassistant/components/rehlko/icons.json @@ -0,0 +1,18 @@ +{ + "entity": { + "sensor": { + "engine_speed": { + "default": "mdi:speedometer" + }, + "engine_state": { + "default": "mdi:engine" + }, + "device_ip_address": { + "default": "mdi:ip-network" + }, + "server_ip_address": { + "default": "mdi:server-network" + } + } + } +} diff --git a/homeassistant/components/rehlko/manifest.json b/homeassistant/components/rehlko/manifest.json new file mode 100644 index 00000000000000..93e284167f54bb --- /dev/null +++ b/homeassistant/components/rehlko/manifest.json @@ -0,0 +1,17 @@ +{ + "domain": "rehlko", + "name": "Rehlko", + "codeowners": ["@bdraco", "@peterager"], + "config_flow": true, + "dhcp": [ + { + "hostname": "kohlergen*", + "macaddress": "00146F*" + } + ], + "documentation": "https://www.home-assistant.io/integrations/rehlko", + "iot_class": "cloud_polling", + "loggers": ["aiokem"], + "quality_scale": "silver", + "requirements": ["aiokem==0.5.6"] +} diff --git a/homeassistant/components/rehlko/quality_scale.yaml b/homeassistant/components/rehlko/quality_scale.yaml new file mode 100644 index 00000000000000..646fac448cc5c4 --- /dev/null +++ b/homeassistant/components/rehlko/quality_scale.yaml @@ -0,0 +1,78 @@ +rules: + # Bronze + action-setup: + status: exempt + comment: | + No custom actions are defined. + appropriate-polling: done + brands: done + common-modules: done + config-flow-test-coverage: done + config-flow: done + dependency-transparency: done + docs-actions: + status: exempt + comment: | + No custom actions are defined. + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: done + entity-event-setup: + status: exempt + comment: | + No explicit event subscriptions. + entity-unique-id: done + has-entity-name: done + runtime-data: done + test-before-configure: done + test-before-setup: done + unique-config-entry: done + # Silver + action-exceptions: + status: exempt + comment: | + No custom actions are defined. + config-entry-unloading: done + docs-configuration-parameters: + status: exempt + comment: | + No configuration parameters. + docs-installation-parameters: done + entity-unavailable: done + integration-owner: done + log-when-unavailable: done + parallel-updates: done + reauthentication-flow: done + test-coverage: done + # Gold + devices: done + diagnostics: todo + discovery-update-info: + status: exempt + comment: Network information not useful as it is a cloud integration. + discovery: done + 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 + dynamic-devices: + status: exempt + comment: | + Device type integration. + entity-category: done + entity-device-class: done + entity-disabled-by-default: done + entity-translations: done + exception-translations: done + icon-translations: done + reconfiguration-flow: todo + repair-issues: todo + stale-devices: todo + + # Platinum + async-dependency: done + inject-websession: todo + strict-typing: todo diff --git a/homeassistant/components/rehlko/sensor.py b/homeassistant/components/rehlko/sensor.py new file mode 100644 index 00000000000000..c2841e5e435cf9 --- /dev/null +++ b/homeassistant/components/rehlko/sensor.py @@ -0,0 +1,203 @@ +"""Support for Rehlko sensors.""" + +from __future__ import annotations + +from dataclasses import dataclass + +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, + SensorStateClass, +) +from homeassistant.const import ( + PERCENTAGE, + REVOLUTIONS_PER_MINUTE, + EntityCategory, + UnitOfElectricPotential, + UnitOfFrequency, + UnitOfPower, + UnitOfPressure, + UnitOfTemperature, + UnitOfTime, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.helpers.typing import StateType + +from .const import DEVICE_DATA_DEVICES, DEVICE_DATA_ID +from .coordinator import RehlkoConfigEntry +from .entity import RehlkoEntity + +# Coordinator is used to centralize the data updates +PARALLEL_UPDATES = 0 + + +@dataclass(frozen=True, kw_only=True) +class RehlkoSensorEntityDescription(SensorEntityDescription): + """Class describing Rehlko sensor entities.""" + + use_device_key: bool = False + + +SENSORS: tuple[RehlkoSensorEntityDescription, ...] = ( + RehlkoSensorEntityDescription( + key="engineSpeedRpm", + translation_key="engine_speed", + entity_category=EntityCategory.DIAGNOSTIC, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=REVOLUTIONS_PER_MINUTE, + ), + RehlkoSensorEntityDescription( + key="engineOilPressurePsi", + translation_key="engine_oil_pressure", + native_unit_of_measurement=UnitOfPressure.PSI, + device_class=SensorDeviceClass.PRESSURE, + state_class=SensorStateClass.MEASUREMENT, + ), + RehlkoSensorEntityDescription( + key="engineCoolantTempF", + translation_key="engine_coolant_temperature", + native_unit_of_measurement=UnitOfTemperature.FAHRENHEIT, + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + ), + RehlkoSensorEntityDescription( + key="batteryVoltageV", + translation_key="battery_voltage", + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + device_class=SensorDeviceClass.VOLTAGE, + state_class=SensorStateClass.MEASUREMENT, + ), + RehlkoSensorEntityDescription( + key="lubeOilTempF", + translation_key="lube_oil_temperature", + native_unit_of_measurement=UnitOfTemperature.FAHRENHEIT, + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + ), + RehlkoSensorEntityDescription( + key="controllerTempF", + translation_key="controller_temperature", + native_unit_of_measurement=UnitOfTemperature.FAHRENHEIT, + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + ), + RehlkoSensorEntityDescription( + key="engineCompartmentTempF", + translation_key="engine_compartment_temperature", + native_unit_of_measurement=UnitOfTemperature.FAHRENHEIT, + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + ), + RehlkoSensorEntityDescription( + key="engineFrequencyHz", + translation_key="engine_frequency", + native_unit_of_measurement=UnitOfFrequency.HERTZ, + device_class=SensorDeviceClass.FREQUENCY, + state_class=SensorStateClass.MEASUREMENT, + ), + RehlkoSensorEntityDescription( + key="totalOperationHours", + translation_key="total_operation", + device_class=SensorDeviceClass.DURATION, + native_unit_of_measurement=UnitOfTime.HOURS, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + RehlkoSensorEntityDescription( + key="totalRuntimeHours", + translation_key="total_runtime", + device_class=SensorDeviceClass.DURATION, + native_unit_of_measurement=UnitOfTime.HOURS, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + use_device_key=True, + ), + RehlkoSensorEntityDescription( + key="runtimeSinceLastMaintenanceHours", + translation_key="runtime_since_last_maintenance", + device_class=SensorDeviceClass.DURATION, + native_unit_of_measurement=UnitOfTime.HOURS, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + RehlkoSensorEntityDescription( + key="deviceIpAddress", + translation_key="device_ip_address", + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + use_device_key=True, + ), + RehlkoSensorEntityDescription( + key="serverIpAddress", + translation_key="server_ip_address", + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + RehlkoSensorEntityDescription( + key="utilityVoltageV", + translation_key="utility_voltage", + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + device_class=SensorDeviceClass.VOLTAGE, + state_class=SensorStateClass.MEASUREMENT, + ), + RehlkoSensorEntityDescription( + key="generatorVoltageAvgV", + translation_key="generator_voltage_avg", + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + device_class=SensorDeviceClass.VOLTAGE, + state_class=SensorStateClass.MEASUREMENT, + ), + RehlkoSensorEntityDescription( + key="generatorLoadW", + translation_key="generator_load", + native_unit_of_measurement=UnitOfPower.WATT, + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + ), + RehlkoSensorEntityDescription( + key="generatorLoadPercent", + translation_key="generator_load_percent", + native_unit_of_measurement=PERCENTAGE, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: RehlkoConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up sensors.""" + + homes = config_entry.runtime_data.homes + coordinators = config_entry.runtime_data.coordinators + async_add_entities( + RehlkoSensorEntity( + coordinators[device_data[DEVICE_DATA_ID]], + device_data[DEVICE_DATA_ID], + device_data, + sensor_description, + sensor_description.use_device_key, + ) + for home_data in homes + for device_data in home_data[DEVICE_DATA_DEVICES] + for sensor_description in SENSORS + ) + + +class RehlkoSensorEntity(RehlkoEntity, SensorEntity): + """Representation of a Rehlko sensor.""" + + @property + def native_value(self) -> StateType: + """Return the sensor state.""" + return self._rehlko_value diff --git a/homeassistant/components/rehlko/strings.json b/homeassistant/components/rehlko/strings.json new file mode 100644 index 00000000000000..e37f3e8684e0f7 --- /dev/null +++ b/homeassistant/components/rehlko/strings.json @@ -0,0 +1,99 @@ +{ + "config": { + "step": { + "user": { + "data": { + "email": "[%key:common::config_flow::data::email%]", + "password": "[%key:common::config_flow::data::password%]" + }, + "data_description": { + "email": "The email used to log in to the Rehlko application.", + "password": "The password used to log in to the Rehlko application." + } + }, + "reauth_confirm": { + "data": { + "password": "[%key:common::config_flow::data::password%]" + }, + "data_description": { + "password": "[%key:component::rehlko::config::step::user::data_description::password%]" + } + } + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_account%]", + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" + } + }, + "entity": { + "sensor": { + "engine_speed": { + "name": "Engine speed" + }, + "engine_oil_pressure": { + "name": "Engine oil pressure" + }, + "engine_coolant_temperature": { + "name": "Engine coolant temperature" + }, + "battery_voltage": { + "name": "Battery voltage" + }, + "lube_oil_temperature": { + "name": "Lube oil temperature" + }, + "controller_temperature": { + "name": "Controller temperature" + }, + "engine_compartment_temperature": { + "name": "Engine compartment temperature" + }, + "engine_frequency": { + "name": "Engine frequency" + }, + "total_operation": { + "name": "Total operation" + }, + "total_runtime": { + "name": "Total runtime" + }, + "runtime_since_last_maintenance": { + "name": "Runtime since last maintenance" + }, + "device_ip_address": { + "name": "Device IP address" + }, + "server_ip_address": { + "name": "Server IP address" + }, + "utility_voltage": { + "name": "Utility voltage" + }, + "generator_voltage_average": { + "name": "Average generator voltage" + }, + "generator_load": { + "name": "Generator load" + }, + "generator_load_percent": { + "name": "Generator load percentage" + } + } + }, + "exceptions": { + "update_failed": { + "message": "Updating data failed after retries." + }, + "invalid_auth": { + "message": "Authentication failed for email {email}." + }, + "cannot_connect": { + "message": "Can not connect to Rehlko servers." + } + } +} diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index ab1b2510d4548d..83074aed83c362 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -518,6 +518,7 @@ "rdw", "recollect_waste", "refoss", + "rehlko", "remote_calendar", "renault", "renson", diff --git a/homeassistant/generated/dhcp.py b/homeassistant/generated/dhcp.py index 39854ff0af6e40..dd85f0bb998d4b 100644 --- a/homeassistant/generated/dhcp.py +++ b/homeassistant/generated/dhcp.py @@ -471,6 +471,11 @@ "domain": "rainforest_eagle", "macaddress": "D8D5B9*", }, + { + "domain": "rehlko", + "hostname": "kohlergen*", + "macaddress": "00146F*", + }, { "domain": "reolink", "hostname": "reolink*", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 1e176cea68a9f9..e981aba33e35a9 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -5375,6 +5375,12 @@ "iot_class": "local_polling", "single_config_entry": true }, + "rehlko": { + "name": "Rehlko", + "integration_type": "hub", + "config_flow": true, + "iot_class": "cloud_polling" + }, "rejseplanen": { "name": "Rejseplanen", "integration_type": "hub", diff --git a/requirements_all.txt b/requirements_all.txt index ab2ae9a37c0d69..20a2578e1efed8 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -285,6 +285,9 @@ aiokafka==0.10.0 # homeassistant.components.kef aiokef==0.2.16 +# homeassistant.components.rehlko +aiokem==0.5.6 + # homeassistant.components.lifx aiolifx-effects==0.3.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index fcfbb43785a9a8..cd2dff24c35bc0 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -267,6 +267,9 @@ aioimaplib==2.0.1 # homeassistant.components.apache_kafka aiokafka==0.10.0 +# homeassistant.components.rehlko +aiokem==0.5.6 + # homeassistant.components.lifx aiolifx-effects==0.3.2 diff --git a/tests/components/rehlko/__init__.py b/tests/components/rehlko/__init__.py new file mode 100644 index 00000000000000..437138a713d25f --- /dev/null +++ b/tests/components/rehlko/__init__.py @@ -0,0 +1 @@ +"""Rehlko Tests Package.""" diff --git a/tests/components/rehlko/conftest.py b/tests/components/rehlko/conftest.py new file mode 100644 index 00000000000000..f5e5a00142b9c5 --- /dev/null +++ b/tests/components/rehlko/conftest.py @@ -0,0 +1,100 @@ +"""Module for testing the Rehlko integration in Home Assistant.""" + +from collections.abc import Generator +from typing import Any +from unittest.mock import AsyncMock, Mock, patch + +import pytest + +from homeassistant.components.rehlko import CONF_REFRESH_TOKEN, DOMAIN +from homeassistant.const import CONF_EMAIL, CONF_PASSWORD +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry, load_json_value_fixture + +TEST_EMAIL = "MyEmail@email.com" +TEST_PASSWORD = "password" +TEST_SUBJECT = TEST_EMAIL.lower() +TEST_REFRESH_TOKEN = "my_refresh_token" + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.rehlko.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + yield mock_setup_entry + + +@pytest.fixture(name="homes") +def rehlko_homes_fixture() -> list[dict[str, Any]]: + """Create sonos favorites fixture.""" + return load_json_value_fixture("homes.json", DOMAIN) + + +@pytest.fixture(name="generator") +def rehlko_generator_fixture() -> dict[str, Any]: + """Create sonos favorites fixture.""" + return load_json_value_fixture("generator.json", DOMAIN) + + +@pytest.fixture(name="rehlko_config_entry") +def rehlko_config_entry_fixture() -> MockConfigEntry: + """Create a config entry fixture.""" + return MockConfigEntry( + domain=DOMAIN, + data={ + CONF_EMAIL: TEST_EMAIL, + CONF_PASSWORD: TEST_PASSWORD, + }, + unique_id=TEST_SUBJECT, + ) + + +@pytest.fixture(name="rehlko_config_entry_with_refresh_token") +def rehlko_config_entry_with_refresh_token_fixture() -> MockConfigEntry: + """Create a config entry fixture with refresh token.""" + return MockConfigEntry( + domain=DOMAIN, + data={ + CONF_EMAIL: TEST_EMAIL, + CONF_PASSWORD: TEST_PASSWORD, + CONF_REFRESH_TOKEN: TEST_REFRESH_TOKEN, + }, + unique_id=TEST_SUBJECT, + ) + + +@pytest.fixture +async def mock_rehlko( + homes: list[dict[str, Any]], + generator: dict[str, Any], +): + """Mock Rehlko instance.""" + with ( + patch("homeassistant.components.rehlko.AioKem", autospec=True) as mock_kem, + patch("homeassistant.components.rehlko.config_flow.AioKem", new=mock_kem), + ): + client = mock_kem.return_value + client.get_homes = AsyncMock(return_value=homes) + client.get_generator_data = AsyncMock(return_value=generator) + client.authenticate = AsyncMock(return_value=None) + client.get_token_subject = Mock(return_value=TEST_SUBJECT) + client.get_refresh_token = AsyncMock(return_value=TEST_REFRESH_TOKEN) + client.set_refresh_token_callback = Mock() + client.set_retry_policy = Mock() + yield client + + +@pytest.fixture +async def load_rehlko_config_entry( + hass: HomeAssistant, + mock_rehlko: Mock, + rehlko_config_entry: MockConfigEntry, +) -> None: + """Load the config entry.""" + rehlko_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(rehlko_config_entry.entry_id) + await hass.async_block_till_done() diff --git a/tests/components/rehlko/fixtures/generator.json b/tests/components/rehlko/fixtures/generator.json new file mode 100644 index 00000000000000..fa1d4d0b45b511 --- /dev/null +++ b/tests/components/rehlko/fixtures/generator.json @@ -0,0 +1,191 @@ +{ + "device": { + "id": 12345, + "serialNumber": "123MGVHR4567", + "displayName": "Generator 1", + "deviceHost": "Oncue", + "hasAcceptedPrivacyPolicy": true, + "address": { + "lat": 41.3341111, + "long": -72.3333111, + "address1": "Highway 66", + "address2": null, + "city": "Somewhere", + "state": "CA", + "postalCode": "00000", + "country": "US" + }, + "product": "Rdc2v4", + "productDisplayName": "RDC 2.4", + "controllerType": "RDC2 (Blue Board)", + "firmwareVersion": "3.4.5", + "currentFirmware": "RDC2.4 3.4.5", + "isConnected": true, + "lastConnectedTimestamp": "2025-04-14T09:30:17+00:00", + "deviceIpAddress": "1.1.1.1:2402", + "macAddress": "91:E1:20:63:10:00", + "status": "ReadyToRun", + "statusUpdateTimestamp": "2025-04-14T09:29:01+00:00", + "dealerOrgs": [ + { + "id": 123, + "businessPartnerNo": "123456", + "name": "Generators R Us", + "e164PhoneNumber": "+199999999999", + "displayPhoneNumber": "(999) 999-9999", + "wizardStep": "OnboardingComplete", + "wizardComplete": true, + "address": { + "lat": null, + "long": null, + "address1": "Highway 66", + "address2": null, + "city": "Revisited", + "state": "CA", + "postalCode": "000000", + "country": null + }, + "userCount": 4, + "technicianCount": 3, + "deviceCount": 71, + "adminEmails": ["admin@gmail.com"] + } + ], + "alertCount": 0, + "model": "Model20KW", + "modelDisplayName": "20 KW", + "lastMaintenanceTimestamp": "2025-04-10T09:12:59", + "nextMaintenanceTimestamp": "2026-04-10T09:12:59", + "maintenancePeriodDays": 365, + "hasServiceAgreement": null, + "totalRuntimeHours": 120.2 + }, + "powerSource": "Utility", + "switchState": "Auto", + "coolingType": "Air", + "connectionType": "Unknown", + "serverIpAddress": "2.2.2.2", + "serviceAgreement": { + "hasServiceAgreement": null, + "beginTimestamp": null, + "term": null, + "termMonths": null, + "termDays": null + }, + "exercise": { + "frequency": "Weekly", + "nextStartTimestamp": "2025-04-19T10:00:00", + "mode": "Unloaded", + "runningMode": null, + "durationMinutes": 20, + "lastStartTimestamp": "2025-04-12T14:00:00+00:00", + "lastEndTimestamp": "2025-04-12T14:19:59+00:00" + }, + "lastRanTimestamp": "2025-04-12T14:00:00+00:00", + "totalRuntimeHours": 120.2, + "totalOperationHours": 33932.3, + "runtimeSinceLastMaintenanceHours": 0.3, + "remoteResetCounterSeconds": 0, + "addedBy": null, + "associatedUsers": ["pete.rage@rage.com"], + "controllerClockTimestamp": "2025-04-15T07:08:50", + "fuelType": "LiquidPropane", + "batteryVoltageV": 13.9, + "engineCoolantTempF": null, + "engineFrequencyHz": 0, + "engineSpeedRpm": 0, + "lubeOilTempF": 42.8, + "controllerTempF": 71.6, + "engineCompartmentTempF": null, + "engineOilPressurePsi": null, + "engineOilPressureOk": true, + "generatorLoadW": 0, + "generatorLoadPercent": 0, + "generatorVoltageAvgV": 0, + "setOutputVoltageV": 240, + "utilityVoltageV": 259.7, + "engineState": "Standby", + "engineStateDisplayNameEn": "Standby", + "loadShed": { + "isConnected": true, + "parameters": [ + { + "definitionId": 1, + "displayName": "HVAC A", + "value": false, + "isReadOnly": false + }, + { + "definitionId": 2, + "displayName": "HVAC B", + "value": false, + "isReadOnly": false + }, + { + "definitionId": 3, + "displayName": "Load A", + "value": false, + "isReadOnly": false + }, + { + "definitionId": 4, + "displayName": "Load B", + "value": false, + "isReadOnly": false + }, + { + "definitionId": 5, + "displayName": "Load C", + "value": false, + "isReadOnly": false + }, + { + "definitionId": 6, + "displayName": "Load D", + "value": false, + "isReadOnly": false + } + ] + }, + "pim": { + "isConnected": false, + "parameters": [ + { + "definitionId": 7, + "displayName": "Digital Output B1 Value", + "value": false, + "isReadOnly": true + }, + { + "definitionId": 8, + "displayName": "Digital Output B2 Value", + "value": false, + "isReadOnly": true + }, + { + "definitionId": 9, + "displayName": "Digital Output B3 Value", + "value": false, + "isReadOnly": false + }, + { + "definitionId": 10, + "displayName": "Digital Output B4 Value", + "value": false, + "isReadOnly": false + }, + { + "definitionId": 11, + "displayName": "Digital Output B5 Value", + "value": false, + "isReadOnly": false + }, + { + "definitionId": 12, + "displayName": "Digital Output B6 Value", + "value": false, + "isReadOnly": false + } + ] + } +} diff --git a/tests/components/rehlko/fixtures/homes.json b/tests/components/rehlko/fixtures/homes.json new file mode 100644 index 00000000000000..5cd29e9111c3a4 --- /dev/null +++ b/tests/components/rehlko/fixtures/homes.json @@ -0,0 +1,82 @@ +[ + { + "id": 12345, + "name": "Generator 1", + "weatherCondition": "Mist", + "weatherTempF": 46.11200000000006, + "weatherTimePeriod": "Day", + "address": { + "lat": 41.334111, + "long": -72.3333111, + "address1": "Highway 66", + "address2": null, + "city": "Somewhere", + "state": "CA", + "postalCode": "000000", + "country": "US" + }, + "devices": [ + { + "id": 12345, + "serialNumber": "123MGVHR4567", + "displayName": "Generator 1", + "deviceHost": "Oncue", + "hasAcceptedPrivacyPolicy": true, + "address": { + "lat": 41.334111, + "long": -72.3333111, + "address1": "Highway 66", + "address2": null, + "city": "Somewhere", + "state": "CA", + "postalCode": "000000", + "country": "US" + }, + "product": "Rdc2v4", + "productDisplayName": "RDC 2.4", + "controllerType": "RDC2 (Blue Board)", + "firmwareVersion": "3.4.5", + "currentFirmware": "RDC2.4 3.4.5", + "isConnected": true, + "lastConnectedTimestamp": "2025-04-14T09:30:17+00:00", + "deviceIpAddress": "1.1.1.1:2402", + "macAddress": "91:E1:20:63:10:00", + "status": "ReadyToRun", + "statusUpdateTimestamp": "2025-04-14T09:29:01+00:00", + "dealerOrgs": [ + { + "id": 123, + "businessPartnerNo": "123456", + "name": "Generators R Us", + "e164PhoneNumber": "+199999999999", + "displayPhoneNumber": "(999) 999-9999", + "wizardStep": "OnboardingComplete", + "wizardComplete": true, + "address": { + "lat": null, + "long": null, + "address1": "Highway 66", + "address2": null, + "city": "Revisited", + "state": "CA", + "postalCode": "000000", + "country": null + }, + "userCount": 4, + "technicianCount": 3, + "deviceCount": 71, + "adminEmails": ["admin@gmail.com"] + } + ], + "alertCount": 0, + "model": "Model20KW", + "modelDisplayName": "20 KW", + "lastMaintenanceTimestamp": "2025-04-10T09:12:59", + "nextMaintenanceTimestamp": "2026-04-10T09:12:59", + "maintenancePeriodDays": 365, + "hasServiceAgreement": null, + "totalRuntimeHours": 120.2 + } + ] + } +] diff --git a/tests/components/rehlko/snapshots/test_sensor.ambr b/tests/components/rehlko/snapshots/test_sensor.ambr new file mode 100644 index 00000000000000..17bb2524b35bb7 --- /dev/null +++ b/tests/components/rehlko/snapshots/test_sensor.ambr @@ -0,0 +1,876 @@ +# serializer version: 1 +# name: test_sensors[sensor.generator_1_battery_voltage-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.generator_1_battery_voltage', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Battery voltage', + 'platform': 'rehlko', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'battery_voltage', + 'unique_id': 'myemail@email.com_12345_batteryVoltageV', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.generator_1_battery_voltage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'Generator 1 Battery voltage', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.generator_1_battery_voltage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '13.9', + }) +# --- +# name: test_sensors[sensor.generator_1_controller_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.generator_1_controller_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': 'Controller temperature', + 'platform': 'rehlko', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'controller_temperature', + 'unique_id': 'myemail@email.com_12345_controllerTempF', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.generator_1_controller_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Generator 1 Controller temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.generator_1_controller_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '22.0', + }) +# --- +# name: test_sensors[sensor.generator_1_device_ip_address-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.generator_1_device_ip_address', + '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': 'Device IP address', + 'platform': 'rehlko', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'device_ip_address', + 'unique_id': 'myemail@email.com_12345_deviceIpAddress', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[sensor.generator_1_device_ip_address-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Generator 1 Device IP address', + }), + 'context': , + 'entity_id': 'sensor.generator_1_device_ip_address', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1.1.1.1:2402', + }) +# --- +# name: test_sensors[sensor.generator_1_engine_compartment_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': , + 'entity_id': 'sensor.generator_1_engine_compartment_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': 'Engine compartment temperature', + 'platform': 'rehlko', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'engine_compartment_temperature', + 'unique_id': 'myemail@email.com_12345_engineCompartmentTempF', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.generator_1_engine_compartment_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Generator 1 Engine compartment temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.generator_1_engine_compartment_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_sensors[sensor.generator_1_engine_coolant_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.generator_1_engine_coolant_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': 'Engine coolant temperature', + 'platform': 'rehlko', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'engine_coolant_temperature', + 'unique_id': 'myemail@email.com_12345_engineCoolantTempF', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.generator_1_engine_coolant_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Generator 1 Engine coolant temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.generator_1_engine_coolant_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_sensors[sensor.generator_1_engine_frequency-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.generator_1_engine_frequency', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Engine frequency', + 'platform': 'rehlko', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'engine_frequency', + 'unique_id': 'myemail@email.com_12345_engineFrequencyHz', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.generator_1_engine_frequency-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'frequency', + 'friendly_name': 'Generator 1 Engine frequency', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.generator_1_engine_frequency', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_sensors[sensor.generator_1_engine_oil_pressure-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.generator_1_engine_oil_pressure', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Engine oil pressure', + 'platform': 'rehlko', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'engine_oil_pressure', + 'unique_id': 'myemail@email.com_12345_engineOilPressurePsi', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.generator_1_engine_oil_pressure-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'pressure', + 'friendly_name': 'Generator 1 Engine oil pressure', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.generator_1_engine_oil_pressure', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_sensors[sensor.generator_1_engine_speed-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': , + 'entity_id': 'sensor.generator_1_engine_speed', + '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': 'Engine speed', + 'platform': 'rehlko', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'engine_speed', + 'unique_id': 'myemail@email.com_12345_engineSpeedRpm', + 'unit_of_measurement': 'rpm', + }) +# --- +# name: test_sensors[sensor.generator_1_engine_speed-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Generator 1 Engine speed', + 'state_class': , + 'unit_of_measurement': 'rpm', + }), + 'context': , + 'entity_id': 'sensor.generator_1_engine_speed', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_sensors[sensor.generator_1_generator_load-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.generator_1_generator_load', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Generator load', + 'platform': 'rehlko', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'generator_load', + 'unique_id': 'myemail@email.com_12345_generatorLoadW', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.generator_1_generator_load-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Generator 1 Generator load', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.generator_1_generator_load', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_sensors[sensor.generator_1_generator_load_percentage-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': , + 'entity_id': 'sensor.generator_1_generator_load_percentage', + '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': 'Generator load percentage', + 'platform': 'rehlko', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'generator_load_percent', + 'unique_id': 'myemail@email.com_12345_generatorLoadPercent', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensors[sensor.generator_1_generator_load_percentage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Generator 1 Generator load percentage', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.generator_1_generator_load_percentage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_sensors[sensor.generator_1_lube_oil_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': , + 'entity_id': 'sensor.generator_1_lube_oil_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': 'Lube oil temperature', + 'platform': 'rehlko', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'lube_oil_temperature', + 'unique_id': 'myemail@email.com_12345_lubeOilTempF', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.generator_1_lube_oil_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Generator 1 Lube oil temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.generator_1_lube_oil_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '6.0', + }) +# --- +# name: test_sensors[sensor.generator_1_runtime_since_last_maintenance-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': , + 'entity_id': 'sensor.generator_1_runtime_since_last_maintenance', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Runtime since last maintenance', + 'platform': 'rehlko', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'runtime_since_last_maintenance', + 'unique_id': 'myemail@email.com_12345_runtimeSinceLastMaintenanceHours', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.generator_1_runtime_since_last_maintenance-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': 'Generator 1 Runtime since last maintenance', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.generator_1_runtime_since_last_maintenance', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.3', + }) +# --- +# name: test_sensors[sensor.generator_1_server_ip_address-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.generator_1_server_ip_address', + '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': 'Server IP address', + 'platform': 'rehlko', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'server_ip_address', + 'unique_id': 'myemail@email.com_12345_serverIpAddress', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[sensor.generator_1_server_ip_address-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Generator 1 Server IP address', + }), + 'context': , + 'entity_id': 'sensor.generator_1_server_ip_address', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2.2.2.2', + }) +# --- +# name: test_sensors[sensor.generator_1_total_operation-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': , + 'entity_id': 'sensor.generator_1_total_operation', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Total operation', + 'platform': 'rehlko', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'total_operation', + 'unique_id': 'myemail@email.com_12345_totalOperationHours', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.generator_1_total_operation-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': 'Generator 1 Total operation', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.generator_1_total_operation', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '33932.3', + }) +# --- +# name: test_sensors[sensor.generator_1_total_runtime-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': , + 'entity_id': 'sensor.generator_1_total_runtime', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Total runtime', + 'platform': 'rehlko', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'total_runtime', + 'unique_id': 'myemail@email.com_12345_totalRuntimeHours', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.generator_1_total_runtime-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': 'Generator 1 Total runtime', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.generator_1_total_runtime', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '120.2', + }) +# --- +# name: test_sensors[sensor.generator_1_utility_voltage-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.generator_1_utility_voltage', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Utility voltage', + 'platform': 'rehlko', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'utility_voltage', + 'unique_id': 'myemail@email.com_12345_utilityVoltageV', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.generator_1_utility_voltage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'Generator 1 Utility voltage', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.generator_1_utility_voltage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '259.7', + }) +# --- +# name: test_sensors[sensor.generator_1_voltage-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.generator_1_voltage', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Voltage', + 'platform': 'rehlko', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'generator_voltage_avg', + 'unique_id': 'myemail@email.com_12345_generatorVoltageAvgV', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.generator_1_voltage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'Generator 1 Voltage', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.generator_1_voltage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- diff --git a/tests/components/rehlko/test_config_flow.py b/tests/components/rehlko/test_config_flow.py new file mode 100644 index 00000000000000..6e3400941abbec --- /dev/null +++ b/tests/components/rehlko/test_config_flow.py @@ -0,0 +1,218 @@ +"""Test the Rehlko config flow.""" + +from unittest.mock import AsyncMock + +from aiokem import AuthenticationCredentialsError +import pytest + +from homeassistant.components.rehlko import DOMAIN +from homeassistant.config_entries import SOURCE_DHCP, SOURCE_USER +from homeassistant.const import CONF_EMAIL, CONF_PASSWORD +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo + +from .conftest import TEST_EMAIL, TEST_PASSWORD, TEST_SUBJECT + +from tests.common import MockConfigEntry + +DHCP_DISCOVERY = DhcpServiceInfo( + ip="1.1.1.1", + hostname="KohlerGen", + macaddress="00146FAABBCC", +) + + +async def test_configure_entry( + hass: HomeAssistant, mock_rehlko: AsyncMock, mock_setup_entry: AsyncMock +) -> None: + """Test we can configure the entry.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {} + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_EMAIL: TEST_EMAIL, + CONF_PASSWORD: TEST_PASSWORD, + }, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == TEST_EMAIL.lower() + assert result["data"] == { + CONF_EMAIL: TEST_EMAIL, + CONF_PASSWORD: TEST_PASSWORD, + } + assert result["result"].unique_id == TEST_SUBJECT + assert mock_setup_entry.call_count == 1 + + +@pytest.mark.parametrize( + ("error", "conf_error"), + [ + (AuthenticationCredentialsError, {CONF_PASSWORD: "invalid_auth"}), + (TimeoutError, {"base": "cannot_connect"}), + (Exception, {"base": "unknown"}), + ], +) +async def test_configure_entry_exceptions( + hass: HomeAssistant, + mock_rehlko: AsyncMock, + error: Exception, + conf_error: dict[str, str], + mock_setup_entry: AsyncMock, +) -> None: + """Test we handle a variety of exceptions and recover by adding new entry.""" + # First try to authenticate and get an error + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + mock_rehlko.authenticate.side_effect = error + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_EMAIL: TEST_EMAIL, + CONF_PASSWORD: TEST_PASSWORD, + }, + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == conf_error + assert mock_setup_entry.call_count == 0 + + # Now try to authenticate again and succeed + # This should create a new entry + mock_rehlko.authenticate.side_effect = None + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_EMAIL: TEST_EMAIL, + CONF_PASSWORD: TEST_PASSWORD, + }, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == TEST_EMAIL.lower() + assert result["data"] == { + CONF_EMAIL: TEST_EMAIL, + CONF_PASSWORD: TEST_PASSWORD, + } + assert result["result"].unique_id == TEST_SUBJECT + assert mock_setup_entry.call_count == 1 + + +async def test_already_configured( + hass: HomeAssistant, rehlko_config_entry: MockConfigEntry, mock_rehlko: AsyncMock +) -> None: + """Test if entry is already configured.""" + rehlko_config_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_EMAIL: TEST_EMAIL, + CONF_PASSWORD: TEST_PASSWORD, + }, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +async def test_reauth( + hass: HomeAssistant, + rehlko_config_entry: MockConfigEntry, + mock_rehlko: AsyncMock, + mock_setup_entry: AsyncMock, +) -> None: + """Test reauth flow.""" + rehlko_config_entry.add_to_hass(hass) + result = await rehlko_config_entry.start_reauth_flow(hass) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_PASSWORD: TEST_PASSWORD + "new", + }, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reauth_successful" + assert rehlko_config_entry.data[CONF_PASSWORD] == TEST_PASSWORD + "new" + assert mock_setup_entry.call_count == 1 + + +async def test_reauth_exception( + hass: HomeAssistant, + rehlko_config_entry: MockConfigEntry, + mock_rehlko: AsyncMock, + mock_setup_entry: AsyncMock, +) -> None: + """Test reauth flow.""" + rehlko_config_entry.add_to_hass(hass) + result = await rehlko_config_entry.start_reauth_flow(hass) + + mock_rehlko.authenticate.side_effect = AuthenticationCredentialsError + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_PASSWORD: TEST_PASSWORD, + }, + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"password": "invalid_auth"} + + mock_rehlko.authenticate.side_effect = None + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_PASSWORD: TEST_PASSWORD + "new", + }, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reauth_successful" + + +async def test_dhcp_discovery( + hass: HomeAssistant, mock_rehlko: AsyncMock, mock_setup_entry: AsyncMock +) -> None: + """Test we can setup from dhcp discovery.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_DHCP}, data=DHCP_DISCOVERY + ) + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {} + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_EMAIL: TEST_EMAIL, + CONF_PASSWORD: TEST_PASSWORD, + }, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + + +async def test_dhcp_discovery_already_set_up( + hass: HomeAssistant, rehlko_config_entry: MockConfigEntry, mock_rehlko: AsyncMock +) -> None: + """Test DHCP discovery aborts if already set up.""" + rehlko_config_entry.add_to_hass(hass) + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_DHCP}, data=DHCP_DISCOVERY + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" diff --git a/tests/components/rehlko/test_sensor.py b/tests/components/rehlko/test_sensor.py new file mode 100644 index 00000000000000..ef3d9d1cf6a179 --- /dev/null +++ b/tests/components/rehlko/test_sensor.py @@ -0,0 +1,85 @@ +"""Tests for the Rehlko sensors.""" + +from __future__ import annotations + +from typing import Any +from unittest.mock import AsyncMock, patch + +from freezegun.api import FrozenDateTimeFactory +import pytest +from syrupy import SnapshotAssertion + +from homeassistant.components.rehlko.coordinator import SCAN_INTERVAL_MINUTES +from homeassistant.const import STATE_UNAVAILABLE, Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform + + +@pytest.fixture(name="platform_sensor", autouse=True) +async def platform_sensor_fixture(): + """Patch Rehlko to only load Sensor platform.""" + with patch("homeassistant.components.rehlko.PLATFORMS", [Platform.SENSOR]): + yield + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_sensors( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, + rehlko_config_entry: MockConfigEntry, + load_rehlko_config_entry: None, +) -> None: + """Test the Rehlko sensors.""" + await snapshot_platform( + hass, entity_registry, snapshot, rehlko_config_entry.entry_id + ) + + +async def test_sensor_availability_device_disconnect( + hass: HomeAssistant, + generator: dict[str, Any], + mock_rehlko: AsyncMock, + load_rehlko_config_entry: None, + freezer: FrozenDateTimeFactory, +) -> None: + """Test the Rehlko sensor availability when device is disconnected.""" + state = hass.states.get("sensor.generator_1_battery_voltage") + assert state + assert state.state == "13.9" + + generator["device"]["isConnected"] = False + + # Move time to next update + freezer.tick(SCAN_INTERVAL_MINUTES) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + state = hass.states.get("sensor.generator_1_battery_voltage") + assert state + assert state.state == STATE_UNAVAILABLE + + +async def test_sensor_availability_poll_failure( + hass: HomeAssistant, + mock_rehlko: AsyncMock, + load_rehlko_config_entry: None, + freezer: FrozenDateTimeFactory, +) -> None: + """Test the Rehlko sensor availability when cloud poll fails.""" + state = hass.states.get("sensor.generator_1_battery_voltage") + assert state + assert state.state == "13.9" + + mock_rehlko.get_generator_data.side_effect = Exception("Test exception") + + # Move time to next update + freezer.tick(SCAN_INTERVAL_MINUTES) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + state = hass.states.get("sensor.generator_1_battery_voltage") + assert state + assert state.state == STATE_UNAVAILABLE From 6168fe006e93ee7f4fb401d7091ebef4c3f7fc72 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Wed, 30 Apr 2025 12:50:28 +0200 Subject: [PATCH 08/37] Remove Oncue integration (#143945) --- CODEOWNERS | 2 - homeassistant/components/oncue/__init__.py | 70 +- .../components/oncue/binary_sensor.py | 50 - homeassistant/components/oncue/config_flow.py | 96 +- homeassistant/components/oncue/const.py | 16 - homeassistant/components/oncue/entity.py | 82 -- homeassistant/components/oncue/manifest.json | 14 +- homeassistant/components/oncue/sensor.py | 217 ----- homeassistant/components/oncue/strings.json | 27 +- homeassistant/components/oncue/types.py | 10 - homeassistant/generated/config_flows.py | 1 - homeassistant/generated/dhcp.py | 5 - homeassistant/generated/integrations.json | 6 - requirements_all.txt | 3 - requirements_test_all.txt | 3 - tests/components/oncue/__init__.py | 880 ------------------ tests/components/oncue/test_binary_sensor.py | 58 -- tests/components/oncue/test_config_flow.py | 192 ---- tests/components/oncue/test_init.py | 133 ++- tests/components/oncue/test_sensor.py | 309 ------ 20 files changed, 95 insertions(+), 2079 deletions(-) delete mode 100644 homeassistant/components/oncue/binary_sensor.py delete mode 100644 homeassistant/components/oncue/const.py delete mode 100644 homeassistant/components/oncue/entity.py delete mode 100644 homeassistant/components/oncue/sensor.py delete mode 100644 homeassistant/components/oncue/types.py delete mode 100644 tests/components/oncue/test_binary_sensor.py delete mode 100644 tests/components/oncue/test_config_flow.py delete mode 100644 tests/components/oncue/test_sensor.py diff --git a/CODEOWNERS b/CODEOWNERS index 1574f8ee826451..490f97879a4ed7 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1081,8 +1081,6 @@ build.json @home-assistant/supervisor /homeassistant/components/ombi/ @larssont /homeassistant/components/onboarding/ @home-assistant/core /tests/components/onboarding/ @home-assistant/core -/homeassistant/components/oncue/ @bdraco @peterager -/tests/components/oncue/ @bdraco @peterager /homeassistant/components/ondilo_ico/ @JeromeHXP /tests/components/ondilo_ico/ @JeromeHXP /homeassistant/components/onedrive/ @zweckj diff --git a/homeassistant/components/oncue/__init__.py b/homeassistant/components/oncue/__init__.py index 19d134a398fbba..53c54290bf9a83 100644 --- a/homeassistant/components/oncue/__init__.py +++ b/homeassistant/components/oncue/__init__.py @@ -2,60 +2,40 @@ from __future__ import annotations -from datetime import timedelta -import logging - -from aiooncue import LoginFailedException, Oncue, OncueDevice - -from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, Platform +from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady -from homeassistant.helpers.aiohttp_client import async_get_clientsession -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator - -from .const import CONNECTION_EXCEPTIONS, DOMAIN # noqa: F401 -from .types import OncueConfigEntry - -PLATFORMS: list[str] = [Platform.BINARY_SENSOR, Platform.SENSOR] +from homeassistant.helpers import issue_registry as ir -_LOGGER = logging.getLogger(__name__) +DOMAIN = "oncue" -async def async_setup_entry(hass: HomeAssistant, entry: OncueConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, _: ConfigEntry) -> bool: """Set up Oncue from a config entry.""" - data = entry.data - websession = async_get_clientsession(hass) - client = Oncue(data[CONF_USERNAME], data[CONF_PASSWORD], websession) - try: - await client.async_login() - except CONNECTION_EXCEPTIONS as ex: - raise ConfigEntryNotReady from ex - except LoginFailedException as ex: - raise ConfigEntryAuthFailed from ex - - async def _async_update() -> dict[str, OncueDevice]: - """Fetch data from Oncue.""" - try: - return await client.async_fetch_all() - except LoginFailedException as ex: - raise ConfigEntryAuthFailed from ex - - coordinator = DataUpdateCoordinator[dict[str, OncueDevice]]( + ir.async_create_issue( hass, - _LOGGER, - config_entry=entry, - name=f"Oncue {entry.data[CONF_USERNAME]}", - update_interval=timedelta(minutes=10), - update_method=_async_update, - always_update=False, + DOMAIN, + DOMAIN, + is_fixable=False, + severity=ir.IssueSeverity.ERROR, + translation_key="integration_removed", + translation_placeholders={ + "entries": "/config/integrations/integration/oncue", + "rehlko": "/config/integrations/integration/rehlko", + }, ) - await coordinator.async_config_entry_first_refresh() - entry.runtime_data = coordinator - await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True -async def async_unload_entry(hass: HomeAssistant, entry: OncueConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" - return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + return True + + +async def async_remove_entry(hass: HomeAssistant, entry: ConfigEntry) -> None: + """Remove a config entry.""" + if not hass.config_entries.async_loaded_entries(DOMAIN): + ir.async_delete_issue(hass, DOMAIN, DOMAIN) + # Remove any remaining disabled or ignored entries + for _entry in hass.config_entries.async_entries(DOMAIN): + hass.async_create_task(hass.config_entries.async_remove(_entry.entry_id)) diff --git a/homeassistant/components/oncue/binary_sensor.py b/homeassistant/components/oncue/binary_sensor.py deleted file mode 100644 index 8dc9ba1be6fb5d..00000000000000 --- a/homeassistant/components/oncue/binary_sensor.py +++ /dev/null @@ -1,50 +0,0 @@ -"""Support for Oncue binary sensors.""" - -from __future__ import annotations - -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 .entity import OncueEntity -from .types import OncueConfigEntry - -SENSOR_TYPES: tuple[BinarySensorEntityDescription, ...] = ( - BinarySensorEntityDescription( - key="NetworkConnectionEstablished", - entity_category=EntityCategory.DIAGNOSTIC, - device_class=BinarySensorDeviceClass.CONNECTIVITY, - ), -) - -SENSOR_MAP = {description.key: description for description in SENSOR_TYPES} - - -async def async_setup_entry( - hass: HomeAssistant, - config_entry: OncueConfigEntry, - async_add_entities: AddConfigEntryEntitiesCallback, -) -> None: - """Set up binary sensors.""" - coordinator = config_entry.runtime_data - devices = coordinator.data - async_add_entities( - OncueBinarySensorEntity(coordinator, device_id, device, sensor, SENSOR_MAP[key]) - for device_id, device in devices.items() - for key, sensor in device.sensors.items() - if key in SENSOR_MAP - ) - - -class OncueBinarySensorEntity(OncueEntity, BinarySensorEntity): - """Representation of an Oncue binary sensor.""" - - @property - def is_on(self) -> bool: - """Return the binary sensor state.""" - return self._oncue_value == "true" diff --git a/homeassistant/components/oncue/config_flow.py b/homeassistant/components/oncue/config_flow.py index 872fe84350bfb7..cf5b3262f0dae3 100644 --- a/homeassistant/components/oncue/config_flow.py +++ b/homeassistant/components/oncue/config_flow.py @@ -1,101 +1,11 @@ -"""Config flow for Oncue integration.""" +"""The Oncue integration.""" -from __future__ import annotations +from homeassistant.config_entries import ConfigFlow -from collections.abc import Mapping -import logging -from typing import Any - -from aiooncue import LoginFailedException, Oncue -import voluptuous as vol - -from homeassistant.config_entries import ConfigFlow, ConfigFlowResult -from homeassistant.const import CONF_PASSWORD, CONF_USERNAME -from homeassistant.helpers.aiohttp_client import async_get_clientsession - -from .const import CONNECTION_EXCEPTIONS, DOMAIN - -_LOGGER = logging.getLogger(__name__) +from . import DOMAIN class OncueConfigFlow(ConfigFlow, domain=DOMAIN): """Handle a config flow for Oncue.""" VERSION = 1 - - async def async_step_user( - self, user_input: dict[str, Any] | None = None - ) -> ConfigFlowResult: - """Handle the initial step.""" - errors: dict[str, str] = {} - - if user_input is not None: - if not (errors := await self._async_validate_or_error(user_input)): - normalized_username = user_input[CONF_USERNAME].lower() - await self.async_set_unique_id(normalized_username) - self._abort_if_unique_id_configured( - updates={ - CONF_USERNAME: user_input[CONF_USERNAME], - CONF_PASSWORD: user_input[CONF_PASSWORD], - } - ) - return self.async_create_entry( - title=normalized_username, data=user_input - ) - - return self.async_show_form( - step_id="user", - data_schema=vol.Schema( - { - vol.Required(CONF_USERNAME): str, - vol.Required(CONF_PASSWORD): str, - } - ), - errors=errors, - ) - - async def _async_validate_or_error(self, config: dict[str, Any]) -> dict[str, str]: - """Validate the user input.""" - errors: dict[str, str] = {} - try: - await Oncue( - config[CONF_USERNAME], - config[CONF_PASSWORD], - async_get_clientsession(self.hass), - ).async_login() - except CONNECTION_EXCEPTIONS: - errors["base"] = "cannot_connect" - except LoginFailedException: - errors[CONF_PASSWORD] = "invalid_auth" - except Exception: - _LOGGER.exception("Unexpected exception") - errors["base"] = "unknown" - return errors - - async def async_step_reauth( - self, entry_data: Mapping[str, Any] - ) -> ConfigFlowResult: - """Handle reauth.""" - return await self.async_step_reauth_confirm() - - async def async_step_reauth_confirm( - self, user_input: dict[str, Any] | None = None - ) -> ConfigFlowResult: - """Handle reauth input.""" - errors: dict[str, str] = {} - reauth_entry = self._get_reauth_entry() - existing_data = reauth_entry.data - description_placeholders: dict[str, str] = { - CONF_USERNAME: existing_data[CONF_USERNAME] - } - if user_input is not None: - new_config = {**existing_data, CONF_PASSWORD: user_input[CONF_PASSWORD]} - if not (errors := await self._async_validate_or_error(new_config)): - return self.async_update_reload_and_abort(reauth_entry, data=new_config) - - return self.async_show_form( - description_placeholders=description_placeholders, - step_id="reauth_confirm", - data_schema=vol.Schema({vol.Required(CONF_PASSWORD): str}), - errors=errors, - ) diff --git a/homeassistant/components/oncue/const.py b/homeassistant/components/oncue/const.py deleted file mode 100644 index bc14133b0d3987..00000000000000 --- a/homeassistant/components/oncue/const.py +++ /dev/null @@ -1,16 +0,0 @@ -"""Constants for the Oncue integration.""" - -import aiohttp -from aiooncue import ServiceFailedException - -DOMAIN = "oncue" - -CONNECTION_EXCEPTIONS = ( - TimeoutError, - aiohttp.ClientError, - ServiceFailedException, -) - -CONNECTION_ESTABLISHED_KEY: str = "NetworkConnectionEstablished" - -VALUE_UNAVAILABLE: str = "--" diff --git a/homeassistant/components/oncue/entity.py b/homeassistant/components/oncue/entity.py deleted file mode 100644 index 55bd86d8912810..00000000000000 --- a/homeassistant/components/oncue/entity.py +++ /dev/null @@ -1,82 +0,0 @@ -"""Support for Oncue sensors.""" - -from __future__ import annotations - -from aiooncue import OncueDevice, OncueSensor - -from homeassistant.const import ATTR_CONNECTIONS -from homeassistant.helpers import device_registry as dr -from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.entity import Entity, EntityDescription -from homeassistant.helpers.update_coordinator import ( - CoordinatorEntity, - DataUpdateCoordinator, -) - -from .const import CONNECTION_ESTABLISHED_KEY, DOMAIN, VALUE_UNAVAILABLE - - -class OncueEntity( - CoordinatorEntity[DataUpdateCoordinator[dict[str, OncueDevice]]], Entity -): - """Representation of an Oncue entity.""" - - _attr_has_entity_name = True - - def __init__( - self, - coordinator: DataUpdateCoordinator[dict[str, OncueDevice]], - device_id: str, - device: OncueDevice, - sensor: OncueSensor, - description: EntityDescription, - ) -> None: - """Initialize the sensor.""" - super().__init__(coordinator) - self.entity_description = description - self._device_id = device_id - self._attr_unique_id = f"{device_id}_{description.key}" - self._attr_name = sensor.display_name - self._attr_device_info = DeviceInfo( - identifiers={(DOMAIN, device_id)}, - name=device.name, - hw_version=device.hardware_version, - sw_version=device.sensors["FirmwareVersion"].display_value, - model=device.sensors["GensetModelNumberSelect"].display_value, - manufacturer="Kohler", - ) - try: - mac_address_hex = hex(int(device.sensors["MacAddress"].value))[2:] - except ValueError: # MacAddress may be invalid if the gateway is offline - return - self._attr_device_info[ATTR_CONNECTIONS] = { - (dr.CONNECTION_NETWORK_MAC, mac_address_hex) - } - - @property - def _oncue_value(self) -> str: - """Return the sensor value.""" - device: OncueDevice = self.coordinator.data[self._device_id] - sensor: OncueSensor = device.sensors[self.entity_description.key] - return sensor.value - - @property - def available(self) -> bool: - """Return if entity is available.""" - # The binary sensor that tracks the connection should not go unavailable. - if self.entity_description.key != CONNECTION_ESTABLISHED_KEY: - # If Kohler returns -- the entity is unavailable. - if self._oncue_value == VALUE_UNAVAILABLE: - return False - # If the cloud is reporting that the generator is not connected - # this also indicates the data is not available. - # The battery voltage sensor reports 0.0 rather than - # -- hence the purpose of this check. - device: OncueDevice = self.coordinator.data[self._device_id] - conn_established: OncueSensor = device.sensors[CONNECTION_ESTABLISHED_KEY] - if ( - conn_established is not None - and conn_established.value == VALUE_UNAVAILABLE - ): - return False - return super().available diff --git a/homeassistant/components/oncue/manifest.json b/homeassistant/components/oncue/manifest.json index 33d56f236692fc..b3744c1bb6588e 100644 --- a/homeassistant/components/oncue/manifest.json +++ b/homeassistant/components/oncue/manifest.json @@ -1,16 +1,10 @@ { "domain": "oncue", "name": "Oncue by Kohler", - "codeowners": ["@bdraco", "@peterager"], - "config_flow": true, - "dhcp": [ - { - "hostname": "kohlergen*", - "macaddress": "00146F*" - } - ], + "codeowners": [], "documentation": "https://www.home-assistant.io/integrations/oncue", + "integration_type": "system", "iot_class": "cloud_polling", - "loggers": ["aiooncue"], - "requirements": ["aiooncue==0.3.9"] + "quality_scale": "legacy", + "requirements": [] } diff --git a/homeassistant/components/oncue/sensor.py b/homeassistant/components/oncue/sensor.py deleted file mode 100644 index 669c34157d4938..00000000000000 --- a/homeassistant/components/oncue/sensor.py +++ /dev/null @@ -1,217 +0,0 @@ -"""Support for Oncue sensors.""" - -from __future__ import annotations - -from aiooncue import OncueDevice, OncueSensor - -from homeassistant.components.sensor import ( - SensorDeviceClass, - SensorEntity, - SensorEntityDescription, - SensorStateClass, -) -from homeassistant.const import ( - PERCENTAGE, - EntityCategory, - UnitOfElectricCurrent, - UnitOfElectricPotential, - UnitOfEnergy, - UnitOfFrequency, - UnitOfPower, - UnitOfPressure, - UnitOfTemperature, -) -from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator - -from .entity import OncueEntity -from .types import OncueConfigEntry - -SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( - SensorEntityDescription( - key="LatestFirmware", - icon="mdi:update", - entity_category=EntityCategory.DIAGNOSTIC, - ), - SensorEntityDescription( - key="EngineSpeed", - icon="mdi:speedometer", - entity_category=EntityCategory.DIAGNOSTIC, - ), - SensorEntityDescription( - key="EngineTargetSpeed", - icon="mdi:speedometer", - entity_category=EntityCategory.DIAGNOSTIC, - ), - SensorEntityDescription( - key="EngineOilPressure", - native_unit_of_measurement=UnitOfPressure.PSI, - device_class=SensorDeviceClass.PRESSURE, - state_class=SensorStateClass.MEASUREMENT, - entity_category=EntityCategory.DIAGNOSTIC, - ), - SensorEntityDescription( - key="EngineCoolantTemperature", - device_class=SensorDeviceClass.TEMPERATURE, - state_class=SensorStateClass.MEASUREMENT, - entity_category=EntityCategory.DIAGNOSTIC, - ), - SensorEntityDescription( - key="BatteryVoltage", - native_unit_of_measurement=UnitOfElectricPotential.VOLT, - device_class=SensorDeviceClass.VOLTAGE, - state_class=SensorStateClass.MEASUREMENT, - entity_category=EntityCategory.DIAGNOSTIC, - ), - SensorEntityDescription( - key="LubeOilTemperature", - device_class=SensorDeviceClass.TEMPERATURE, - state_class=SensorStateClass.MEASUREMENT, - entity_category=EntityCategory.DIAGNOSTIC, - ), - SensorEntityDescription( - key="GensetControllerTemperature", - device_class=SensorDeviceClass.TEMPERATURE, - state_class=SensorStateClass.MEASUREMENT, - entity_category=EntityCategory.DIAGNOSTIC, - ), - SensorEntityDescription( - key="EngineCompartmentTemperature", - device_class=SensorDeviceClass.TEMPERATURE, - state_class=SensorStateClass.MEASUREMENT, - entity_category=EntityCategory.DIAGNOSTIC, - ), - SensorEntityDescription( - key="GeneratorTrueTotalPower", - native_unit_of_measurement=UnitOfPower.WATT, - device_class=SensorDeviceClass.POWER, - state_class=SensorStateClass.MEASUREMENT, - entity_category=EntityCategory.DIAGNOSTIC, - ), - SensorEntityDescription( - key="GeneratorTruePercentOfRatedPower", - native_unit_of_measurement=PERCENTAGE, - entity_category=EntityCategory.DIAGNOSTIC, - ), - SensorEntityDescription( - key="GeneratorVoltageAverageLineToLine", - native_unit_of_measurement=UnitOfElectricPotential.VOLT, - device_class=SensorDeviceClass.VOLTAGE, - state_class=SensorStateClass.MEASUREMENT, - entity_category=EntityCategory.DIAGNOSTIC, - ), - SensorEntityDescription( - key="GeneratorFrequency", - native_unit_of_measurement=UnitOfFrequency.HERTZ, - device_class=SensorDeviceClass.FREQUENCY, - state_class=SensorStateClass.MEASUREMENT, - entity_category=EntityCategory.DIAGNOSTIC, - ), - SensorEntityDescription(key="GensetState", icon="mdi:home-lightning-bolt"), - SensorEntityDescription( - key="GensetControllerTotalOperationTime", - icon="mdi:hours-24", - entity_category=EntityCategory.DIAGNOSTIC, - ), - SensorEntityDescription( - key="EngineTotalRunTime", - icon="mdi:hours-24", - entity_category=EntityCategory.DIAGNOSTIC, - ), - SensorEntityDescription( - key="EngineTotalRunTimeLoaded", - icon="mdi:hours-24", - entity_category=EntityCategory.DIAGNOSTIC, - ), - SensorEntityDescription(key="AtsContactorPosition", icon="mdi:electric-switch"), - SensorEntityDescription( - key="IPAddress", - icon="mdi:ip-network", - entity_category=EntityCategory.DIAGNOSTIC, - ), - SensorEntityDescription( - key="ConnectedServerIPAddress", - icon="mdi:server-network", - entity_category=EntityCategory.DIAGNOSTIC, - ), - SensorEntityDescription( - key="Source1VoltageAverageLineToLine", - native_unit_of_measurement=UnitOfElectricPotential.VOLT, - device_class=SensorDeviceClass.VOLTAGE, - state_class=SensorStateClass.MEASUREMENT, - entity_category=EntityCategory.DIAGNOSTIC, - ), - SensorEntityDescription( - key="Source2VoltageAverageLineToLine", - native_unit_of_measurement=UnitOfElectricPotential.VOLT, - device_class=SensorDeviceClass.VOLTAGE, - state_class=SensorStateClass.MEASUREMENT, - entity_category=EntityCategory.DIAGNOSTIC, - ), - SensorEntityDescription( - key="GensetTotalEnergy", - native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, - device_class=SensorDeviceClass.ENERGY, - state_class=SensorStateClass.TOTAL_INCREASING, - ), - SensorEntityDescription( - key="EngineTotalNumberOfStarts", - icon="mdi:engine", - entity_category=EntityCategory.DIAGNOSTIC, - ), - SensorEntityDescription( - key="GeneratorCurrentAverage", - native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, - device_class=SensorDeviceClass.CURRENT, - state_class=SensorStateClass.MEASUREMENT, - entity_category=EntityCategory.DIAGNOSTIC, - ), -) - -SENSOR_MAP = {description.key: description for description in SENSOR_TYPES} - -UNIT_MAPPINGS = { - "C": UnitOfTemperature.CELSIUS, - "F": UnitOfTemperature.FAHRENHEIT, -} - - -async def async_setup_entry( - hass: HomeAssistant, - config_entry: OncueConfigEntry, - async_add_entities: AddConfigEntryEntitiesCallback, -) -> None: - """Set up sensors.""" - coordinator = config_entry.runtime_data - devices = coordinator.data - async_add_entities( - OncueSensorEntity(coordinator, device_id, device, sensor, SENSOR_MAP[key]) - for device_id, device in devices.items() - for key, sensor in device.sensors.items() - if key in SENSOR_MAP - ) - - -class OncueSensorEntity(OncueEntity, SensorEntity): - """Representation of an Oncue sensor.""" - - def __init__( - self, - coordinator: DataUpdateCoordinator[dict[str, OncueDevice]], - device_id: str, - device: OncueDevice, - sensor: OncueSensor, - description: SensorEntityDescription, - ) -> None: - """Initialize the sensor.""" - super().__init__(coordinator, device_id, device, sensor, description) - if not description.native_unit_of_measurement and sensor.unit is not None: - self._attr_native_unit_of_measurement = UNIT_MAPPINGS.get( - sensor.unit, sensor.unit - ) - - @property - def native_value(self) -> str: - """Return the sensors state.""" - return self._oncue_value diff --git a/homeassistant/components/oncue/strings.json b/homeassistant/components/oncue/strings.json index ce7561962a23e8..6581555ff9e0f7 100644 --- a/homeassistant/components/oncue/strings.json +++ b/homeassistant/components/oncue/strings.json @@ -1,27 +1,8 @@ { - "config": { - "step": { - "user": { - "data": { - "username": "[%key:common::config_flow::data::username%]", - "password": "[%key:common::config_flow::data::password%]" - } - }, - "reauth_confirm": { - "description": "Re-authenticate Oncue account {username}", - "data": { - "password": "[%key:common::config_flow::data::password%]" - } - } - }, - "error": { - "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", - "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", - "unknown": "[%key:common::config_flow::error::unknown%]" - }, - "abort": { - "already_configured": "[%key:common::config_flow::abort::already_configured_account%]", - "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" + "issues": { + "integration_removed": { + "title": "The Oncue integration has been removed", + "description": "The Oncue integration has been removed from Home Assistant.\n\nThe Oncue service has been discontinued and [Rehlko]({rehlko}) is the integration to keep using it.\n\nTo resolve this issue, please remove the (now defunct) integration entries from your Home Assistant setup. [Click here to see your existing Oncue integration entries]({entries})." } } } diff --git a/homeassistant/components/oncue/types.py b/homeassistant/components/oncue/types.py deleted file mode 100644 index 89dd7095d596e9..00000000000000 --- a/homeassistant/components/oncue/types.py +++ /dev/null @@ -1,10 +0,0 @@ -"""Support for Oncue types.""" - -from __future__ import annotations - -from aiooncue import OncueDevice - -from homeassistant.config_entries import ConfigEntry -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator - -type OncueConfigEntry = ConfigEntry[DataUpdateCoordinator[dict[str, OncueDevice]]] diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 83074aed83c362..8174dfc60b1c8c 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -440,7 +440,6 @@ "ohme", "ollama", "omnilogic", - "oncue", "ondilo_ico", "onedrive", "onewire", diff --git a/homeassistant/generated/dhcp.py b/homeassistant/generated/dhcp.py index dd85f0bb998d4b..53506ed174850a 100644 --- a/homeassistant/generated/dhcp.py +++ b/homeassistant/generated/dhcp.py @@ -404,11 +404,6 @@ "domain": "obihai", "macaddress": "9CADEF*", }, - { - "domain": "oncue", - "hostname": "kohlergen*", - "macaddress": "00146F*", - }, { "domain": "onvif", "registered_devices": True, diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index e981aba33e35a9..33b24f064d55c2 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -4563,12 +4563,6 @@ "iot_class": "cloud_polling", "single_config_entry": true }, - "oncue": { - "name": "Oncue by Kohler", - "integration_type": "hub", - "config_flow": true, - "iot_class": "cloud_polling" - }, "ondilo_ico": { "name": "Ondilo ICO", "integration_type": "hub", diff --git a/requirements_all.txt b/requirements_all.txt index 20a2578e1efed8..2272af56c50bd0 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -324,9 +324,6 @@ aiontfy==0.5.1 # homeassistant.components.nut aionut==4.3.4 -# homeassistant.components.oncue -aiooncue==0.3.9 - # homeassistant.components.openexchangerates aioopenexchangerates==0.6.8 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index cd2dff24c35bc0..723d0f352c345a 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -306,9 +306,6 @@ aiontfy==0.5.1 # homeassistant.components.nut aionut==4.3.4 -# homeassistant.components.oncue -aiooncue==0.3.9 - # homeassistant.components.openexchangerates aioopenexchangerates==0.6.8 diff --git a/tests/components/oncue/__init__.py b/tests/components/oncue/__init__.py index d88774307c0ef6..d7821861e88954 100644 --- a/tests/components/oncue/__init__.py +++ b/tests/components/oncue/__init__.py @@ -1,881 +1 @@ """Tests for the Oncue integration.""" - -from contextlib import contextmanager -from unittest.mock import patch - -from aiooncue import LoginFailedException, OncueDevice, OncueSensor - -MOCK_ASYNC_FETCH_ALL = { - "123456": OncueDevice( - name="My Generator", - state="Off", - product_name="RDC 2.4", - hardware_version="319", - serial_number="SERIAL", - sensors={ - "Product": OncueSensor( - name="Product", - display_name="Controller Type", - value="RDC 2.4", - display_value="RDC 2.4", - unit=None, - ), - "FirmwareVersion": OncueSensor( - name="FirmwareVersion", - display_name="Current Firmware", - value="2.0.6", - display_value="2.0.6", - unit=None, - ), - "LatestFirmware": OncueSensor( - name="LatestFirmware", - display_name="Latest Firmware", - value="2.0.6", - display_value="2.0.6", - unit=None, - ), - "EngineSpeed": OncueSensor( - name="EngineSpeed", - display_name="Engine Speed", - value="0", - display_value="0 R/min", - unit="R/min", - ), - "EngineTargetSpeed": OncueSensor( - name="EngineTargetSpeed", - display_name="Engine Target Speed", - value="0", - display_value="0 R/min", - unit="R/min", - ), - "EngineOilPressure": OncueSensor( - name="EngineOilPressure", - display_name="Engine Oil Pressure", - value=0, - display_value="0 Psi", - unit="Psi", - ), - "EngineCoolantTemperature": OncueSensor( - name="EngineCoolantTemperature", - display_name="Engine Coolant Temperature", - value=32, - display_value="32 F", - unit="F", - ), - "BatteryVoltage": OncueSensor( - name="BatteryVoltage", - display_name="Battery Voltage", - value="13.4", - display_value="13.4 V", - unit="V", - ), - "LubeOilTemperature": OncueSensor( - name="LubeOilTemperature", - display_name="Lube Oil Temperature", - value=32, - display_value="32 F", - unit="F", - ), - "GensetControllerTemperature": OncueSensor( - name="GensetControllerTemperature", - display_name="Generator Controller Temperature", - value=84.2, - display_value="84.2 F", - unit="F", - ), - "EngineCompartmentTemperature": OncueSensor( - name="EngineCompartmentTemperature", - display_name="Engine Compartment Temperature", - value=62.6, - display_value="62.6 F", - unit="F", - ), - "GeneratorTrueTotalPower": OncueSensor( - name="GeneratorTrueTotalPower", - display_name="Generator True Total Power", - value="0.0", - display_value="0.0 W", - unit="W", - ), - "GeneratorTruePercentOfRatedPower": OncueSensor( - name="GeneratorTruePercentOfRatedPower", - display_name="Generator True Percent Of Rated Power", - value="0", - display_value="0 %", - unit="%", - ), - "GeneratorVoltageAB": OncueSensor( - name="GeneratorVoltageAB", - display_name="Generator Voltage AB", - value="0.0", - display_value="0.0 V", - unit="V", - ), - "GeneratorVoltageAverageLineToLine": OncueSensor( - name="GeneratorVoltageAverageLineToLine", - display_name="Generator Voltage Average Line To Line", - value="0.0", - display_value="0.0 V", - unit="V", - ), - "GeneratorCurrentAverage": OncueSensor( - name="GeneratorCurrentAverage", - display_name="Generator Current Average", - value="0.0", - display_value="0.0 A", - unit="A", - ), - "GeneratorFrequency": OncueSensor( - name="GeneratorFrequency", - display_name="Generator Frequency", - value="0.0", - display_value="0.0 Hz", - unit="Hz", - ), - "GensetSerialNumber": OncueSensor( - name="GensetSerialNumber", - display_name="Generator Serial Number", - value="33FDGMFR0026", - display_value="33FDGMFR0026", - unit=None, - ), - "GensetState": OncueSensor( - name="GensetState", - display_name="Generator State", - value="Off", - display_value="Off", - unit=None, - ), - "GensetControllerSerialNumber": OncueSensor( - name="GensetControllerSerialNumber", - display_name="Generator Controller Serial Number", - value="-1", - display_value="-1", - unit=None, - ), - "GensetModelNumberSelect": OncueSensor( - name="GensetModelNumberSelect", - display_name="Genset Model Number Select", - value="38 RCLB", - display_value="38 RCLB", - unit=None, - ), - "GensetControllerClockTime": OncueSensor( - name="GensetControllerClockTime", - display_name="Generator Controller Clock Time", - value="2022-01-13 18:08:13", - display_value="2022-01-13 18:08:13", - unit=None, - ), - "GensetControllerTotalOperationTime": OncueSensor( - name="GensetControllerTotalOperationTime", - display_name="Generator Controller Total Operation Time", - value="16770.8", - display_value="16770.8 h", - unit="h", - ), - "EngineTotalRunTime": OncueSensor( - name="EngineTotalRunTime", - display_name="Engine Total Run Time", - value="28.1", - display_value="28.1 h", - unit="h", - ), - "EngineTotalRunTimeLoaded": OncueSensor( - name="EngineTotalRunTimeLoaded", - display_name="Engine Total Run Time Loaded", - value="5.5", - display_value="5.5 h", - unit="h", - ), - "EngineTotalNumberOfStarts": OncueSensor( - name="EngineTotalNumberOfStarts", - display_name="Engine Total Number Of Starts", - value="101", - display_value="101", - unit=None, - ), - "GensetTotalEnergy": OncueSensor( - name="GensetTotalEnergy", - display_name="Genset Total Energy", - value="1.2022309E7", - display_value="1.2022309E7 kWh", - unit="kWh", - ), - "AtsContactorPosition": OncueSensor( - name="AtsContactorPosition", - display_name="Ats Contactor Position", - value="Source1", - display_value="Source1", - unit=None, - ), - "AtsSourcesAvailable": OncueSensor( - name="AtsSourcesAvailable", - display_name="Ats Sources Available", - value="Source1", - display_value="Source1", - unit=None, - ), - "Source1VoltageAverageLineToLine": OncueSensor( - name="Source1VoltageAverageLineToLine", - display_name="Source1 Voltage Average Line To Line", - value="253.5", - display_value="253.5 V", - unit="V", - ), - "Source2VoltageAverageLineToLine": OncueSensor( - name="Source2VoltageAverageLineToLine", - display_name="Source2 Voltage Average Line To Line", - value="0.0", - display_value="0.0 V", - unit="V", - ), - "IPAddress": OncueSensor( - name="IPAddress", - display_name="IP Address", - value="1.2.3.4:1026", - display_value="1.2.3.4:1026", - unit=None, - ), - "MacAddress": OncueSensor( - name="MacAddress", - display_name="Mac Address", - value="221157033710592", - display_value="221157033710592", - unit=None, - ), - "ConnectedServerIPAddress": OncueSensor( - name="ConnectedServerIPAddress", - display_name="Connected Server IP Address", - value="40.117.195.28", - display_value="40.117.195.28", - unit=None, - ), - "NetworkConnectionEstablished": OncueSensor( - name="NetworkConnectionEstablished", - display_name="Network Connection Established", - value="true", - display_value="True", - unit=None, - ), - "SerialNumber": OncueSensor( - name="SerialNumber", - display_name="Serial Number", - value="1073879692", - display_value="1073879692", - unit=None, - ), - }, - ) -} - - -MOCK_ASYNC_FETCH_ALL_OFFLINE_DEVICE = { - "456789": OncueDevice( - name="My Generator", - state="Off", - product_name="RDC 2.4", - hardware_version="319", - serial_number="SERIAL", - sensors={ - "Product": OncueSensor( - name="Product", - display_name="Controller Type", - value="RDC 2.4", - display_value="RDC 2.4", - unit=None, - ), - "FirmwareVersion": OncueSensor( - name="FirmwareVersion", - display_name="Current Firmware", - value="2.0.6", - display_value="2.0.6", - unit=None, - ), - "LatestFirmware": OncueSensor( - name="LatestFirmware", - display_name="Latest Firmware", - value="2.0.6", - display_value="2.0.6", - unit=None, - ), - "EngineSpeed": OncueSensor( - name="EngineSpeed", - display_name="Engine Speed", - value="0", - display_value="0 R/min", - unit="R/min", - ), - "EngineTargetSpeed": OncueSensor( - name="EngineTargetSpeed", - display_name="Engine Target Speed", - value="0", - display_value="0 R/min", - unit="R/min", - ), - "EngineOilPressure": OncueSensor( - name="EngineOilPressure", - display_name="Engine Oil Pressure", - value=0, - display_value="0 Psi", - unit="Psi", - ), - "EngineCoolantTemperature": OncueSensor( - name="EngineCoolantTemperature", - display_name="Engine Coolant Temperature", - value=32, - display_value="32 F", - unit="F", - ), - "BatteryVoltage": OncueSensor( - name="BatteryVoltage", - display_name="Battery Voltage", - value="13.4", - display_value="13.4 V", - unit="V", - ), - "LubeOilTemperature": OncueSensor( - name="LubeOilTemperature", - display_name="Lube Oil Temperature", - value=32, - display_value="32 F", - unit="F", - ), - "GensetControllerTemperature": OncueSensor( - name="GensetControllerTemperature", - display_name="Generator Controller Temperature", - value=84.2, - display_value="84.2 F", - unit="F", - ), - "EngineCompartmentTemperature": OncueSensor( - name="EngineCompartmentTemperature", - display_name="Engine Compartment Temperature", - value=62.6, - display_value="62.6 F", - unit="F", - ), - "GeneratorTrueTotalPower": OncueSensor( - name="GeneratorTrueTotalPower", - display_name="Generator True Total Power", - value="0.0", - display_value="0.0 W", - unit="W", - ), - "GeneratorTruePercentOfRatedPower": OncueSensor( - name="GeneratorTruePercentOfRatedPower", - display_name="Generator True Percent Of Rated Power", - value="0", - display_value="0 %", - unit="%", - ), - "GeneratorVoltageAB": OncueSensor( - name="GeneratorVoltageAB", - display_name="Generator Voltage AB", - value="0.0", - display_value="0.0 V", - unit="V", - ), - "GeneratorVoltageAverageLineToLine": OncueSensor( - name="GeneratorVoltageAverageLineToLine", - display_name="Generator Voltage Average Line To Line", - value="0.0", - display_value="0.0 V", - unit="V", - ), - "GeneratorCurrentAverage": OncueSensor( - name="GeneratorCurrentAverage", - display_name="Generator Current Average", - value="0.0", - display_value="0.0 A", - unit="A", - ), - "GeneratorFrequency": OncueSensor( - name="GeneratorFrequency", - display_name="Generator Frequency", - value="0.0", - display_value="0.0 Hz", - unit="Hz", - ), - "GensetSerialNumber": OncueSensor( - name="GensetSerialNumber", - display_name="Generator Serial Number", - value="33FDGMFR0026", - display_value="33FDGMFR0026", - unit=None, - ), - "GensetState": OncueSensor( - name="GensetState", - display_name="Generator State", - value="Off", - display_value="Off", - unit=None, - ), - "GensetControllerSerialNumber": OncueSensor( - name="GensetControllerSerialNumber", - display_name="Generator Controller Serial Number", - value="-1", - display_value="-1", - unit=None, - ), - "GensetModelNumberSelect": OncueSensor( - name="GensetModelNumberSelect", - display_name="Genset Model Number Select", - value="38 RCLB", - display_value="38 RCLB", - unit=None, - ), - "GensetControllerClockTime": OncueSensor( - name="GensetControllerClockTime", - display_name="Generator Controller Clock Time", - value="2022-01-13 18:08:13", - display_value="2022-01-13 18:08:13", - unit=None, - ), - "GensetControllerTotalOperationTime": OncueSensor( - name="GensetControllerTotalOperationTime", - display_name="Generator Controller Total Operation Time", - value="16770.8", - display_value="16770.8 h", - unit="h", - ), - "EngineTotalRunTime": OncueSensor( - name="EngineTotalRunTime", - display_name="Engine Total Run Time", - value="28.1", - display_value="28.1 h", - unit="h", - ), - "EngineTotalRunTimeLoaded": OncueSensor( - name="EngineTotalRunTimeLoaded", - display_name="Engine Total Run Time Loaded", - value="5.5", - display_value="5.5 h", - unit="h", - ), - "EngineTotalNumberOfStarts": OncueSensor( - name="EngineTotalNumberOfStarts", - display_name="Engine Total Number Of Starts", - value="101", - display_value="101", - unit=None, - ), - "GensetTotalEnergy": OncueSensor( - name="GensetTotalEnergy", - display_name="Genset Total Energy", - value="1.2022309E7", - display_value="1.2022309E7 kWh", - unit="kWh", - ), - "AtsContactorPosition": OncueSensor( - name="AtsContactorPosition", - display_name="Ats Contactor Position", - value="Source1", - display_value="Source1", - unit=None, - ), - "AtsSourcesAvailable": OncueSensor( - name="AtsSourcesAvailable", - display_name="Ats Sources Available", - value="Source1", - display_value="Source1", - unit=None, - ), - "Source1VoltageAverageLineToLine": OncueSensor( - name="Source1VoltageAverageLineToLine", - display_name="Source1 Voltage Average Line To Line", - value="253.5", - display_value="253.5 V", - unit="V", - ), - "Source2VoltageAverageLineToLine": OncueSensor( - name="Source2VoltageAverageLineToLine", - display_name="Source2 Voltage Average Line To Line", - value="0.0", - display_value="0.0 V", - unit="V", - ), - "IPAddress": OncueSensor( - name="IPAddress", - display_name="IP Address", - value="1.2.3.4:1026", - display_value="1.2.3.4:1026", - unit=None, - ), - "MacAddress": OncueSensor( - name="MacAddress", - display_name="Mac Address", - value="--", - display_value="--", - unit=None, - ), - "ConnectedServerIPAddress": OncueSensor( - name="ConnectedServerIPAddress", - display_name="Connected Server IP Address", - value="40.117.195.28", - display_value="40.117.195.28", - unit=None, - ), - "NetworkConnectionEstablished": OncueSensor( - name="NetworkConnectionEstablished", - display_name="Network Connection Established", - value="true", - display_value="True", - unit=None, - ), - "SerialNumber": OncueSensor( - name="SerialNumber", - display_name="Serial Number", - value="1073879692", - display_value="1073879692", - unit=None, - ), - }, - ) -} - -MOCK_ASYNC_FETCH_ALL_UNAVAILABLE_DEVICE = { - "456789": OncueDevice( - name="My Generator", - state="Off", - product_name="RDC 2.4", - hardware_version="319", - serial_number="SERIAL", - sensors={ - "Product": OncueSensor( - name="Product", - display_name="Controller Type", - value="--", - display_value="RDC 2.4", - unit=None, - ), - "FirmwareVersion": OncueSensor( - name="FirmwareVersion", - display_name="Current Firmware", - value="--", - display_value="2.0.6", - unit=None, - ), - "LatestFirmware": OncueSensor( - name="LatestFirmware", - display_name="Latest Firmware", - value="--", - display_value="2.0.6", - unit=None, - ), - "EngineSpeed": OncueSensor( - name="EngineSpeed", - display_name="Engine Speed", - value="--", - display_value="0 R/min", - unit="R/min", - ), - "EngineTargetSpeed": OncueSensor( - name="EngineTargetSpeed", - display_name="Engine Target Speed", - value="--", - display_value="0 R/min", - unit="R/min", - ), - "EngineOilPressure": OncueSensor( - name="EngineOilPressure", - display_name="Engine Oil Pressure", - value="--", - display_value="0 Psi", - unit="Psi", - ), - "EngineCoolantTemperature": OncueSensor( - name="EngineCoolantTemperature", - display_name="Engine Coolant Temperature", - value="--", - display_value="32 F", - unit="F", - ), - "BatteryVoltage": OncueSensor( - name="BatteryVoltage", - display_name="Battery Voltage", - value="0.0", - display_value="13.4 V", - unit="V", - ), - "LubeOilTemperature": OncueSensor( - name="LubeOilTemperature", - display_name="Lube Oil Temperature", - value="--", - display_value="32 F", - unit="F", - ), - "GensetControllerTemperature": OncueSensor( - name="GensetControllerTemperature", - display_name="Generator Controller Temperature", - value="--", - display_value="84.2 F", - unit="F", - ), - "EngineCompartmentTemperature": OncueSensor( - name="EngineCompartmentTemperature", - display_name="Engine Compartment Temperature", - value="--", - display_value="62.6 F", - unit="F", - ), - "GeneratorTrueTotalPower": OncueSensor( - name="GeneratorTrueTotalPower", - display_name="Generator True Total Power", - value="--", - display_value="0.0 W", - unit="W", - ), - "GeneratorTruePercentOfRatedPower": OncueSensor( - name="GeneratorTruePercentOfRatedPower", - display_name="Generator True Percent Of Rated Power", - value="--", - display_value="0 %", - unit="%", - ), - "GeneratorVoltageAB": OncueSensor( - name="GeneratorVoltageAB", - display_name="Generator Voltage AB", - value="--", - display_value="0.0 V", - unit="V", - ), - "GeneratorVoltageAverageLineToLine": OncueSensor( - name="GeneratorVoltageAverageLineToLine", - display_name="Generator Voltage Average Line To Line", - value="--", - display_value="0.0 V", - unit="V", - ), - "GeneratorCurrentAverage": OncueSensor( - name="GeneratorCurrentAverage", - display_name="Generator Current Average", - value="--", - display_value="0.0 A", - unit="A", - ), - "GeneratorFrequency": OncueSensor( - name="GeneratorFrequency", - display_name="Generator Frequency", - value="--", - display_value="0.0 Hz", - unit="Hz", - ), - "GensetSerialNumber": OncueSensor( - name="GensetSerialNumber", - display_name="Generator Serial Number", - value="--", - display_value="33FDGMFR0026", - unit=None, - ), - "GensetState": OncueSensor( - name="GensetState", - display_name="Generator State", - value="--", - display_value="Off", - unit=None, - ), - "GensetControllerSerialNumber": OncueSensor( - name="GensetControllerSerialNumber", - display_name="Generator Controller Serial Number", - value="--", - display_value="-1", - unit=None, - ), - "GensetModelNumberSelect": OncueSensor( - name="GensetModelNumberSelect", - display_name="Genset Model Number Select", - value="--", - display_value="38 RCLB", - unit=None, - ), - "GensetControllerClockTime": OncueSensor( - name="GensetControllerClockTime", - display_name="Generator Controller Clock Time", - value="--", - display_value="2022-01-13 18:08:13", - unit=None, - ), - "GensetControllerTotalOperationTime": OncueSensor( - name="GensetControllerTotalOperationTime", - display_name="Generator Controller Total Operation Time", - value="--", - display_value="16770.8 h", - unit="h", - ), - "EngineTotalRunTime": OncueSensor( - name="EngineTotalRunTime", - display_name="Engine Total Run Time", - value="--", - display_value="28.1 h", - unit="h", - ), - "EngineTotalRunTimeLoaded": OncueSensor( - name="EngineTotalRunTimeLoaded", - display_name="Engine Total Run Time Loaded", - value="--", - display_value="5.5 h", - unit="h", - ), - "EngineTotalNumberOfStarts": OncueSensor( - name="EngineTotalNumberOfStarts", - display_name="Engine Total Number Of Starts", - value="--", - display_value="101", - unit=None, - ), - "GensetTotalEnergy": OncueSensor( - name="GensetTotalEnergy", - display_name="Genset Total Energy", - value="--", - display_value="1.2022309E7 kWh", - unit="kWh", - ), - "AtsContactorPosition": OncueSensor( - name="AtsContactorPosition", - display_name="Ats Contactor Position", - value="--", - display_value="Source1", - unit=None, - ), - "AtsSourcesAvailable": OncueSensor( - name="AtsSourcesAvailable", - display_name="Ats Sources Available", - value="--", - display_value="Source1", - unit=None, - ), - "Source1VoltageAverageLineToLine": OncueSensor( - name="Source1VoltageAverageLineToLine", - display_name="Source1 Voltage Average Line To Line", - value="--", - display_value="253.5 V", - unit="V", - ), - "Source2VoltageAverageLineToLine": OncueSensor( - name="Source2VoltageAverageLineToLine", - display_name="Source2 Voltage Average Line To Line", - value="--", - display_value="0.0 V", - unit="V", - ), - "IPAddress": OncueSensor( - name="IPAddress", - display_name="IP Address", - value="--", - display_value="1.2.3.4:1026", - unit=None, - ), - "MacAddress": OncueSensor( - name="MacAddress", - display_name="Mac Address", - value="--", - display_value="--", - unit=None, - ), - "ConnectedServerIPAddress": OncueSensor( - name="ConnectedServerIPAddress", - display_name="Connected Server IP Address", - value="--", - display_value="40.117.195.28", - unit=None, - ), - "NetworkConnectionEstablished": OncueSensor( - name="NetworkConnectionEstablished", - display_name="Network Connection Established", - value="--", - display_value="True", - unit=None, - ), - "SerialNumber": OncueSensor( - name="SerialNumber", - display_name="Serial Number", - value="--", - display_value="1073879692", - unit=None, - ), - }, - ) -} - - -def _patch_login_and_data(): - @contextmanager - def _patcher(): - with ( - patch( - "homeassistant.components.oncue.Oncue.async_login", - ), - patch( - "homeassistant.components.oncue.Oncue.async_fetch_all", - return_value=MOCK_ASYNC_FETCH_ALL, - ), - ): - yield - - return _patcher() - - -def _patch_login_and_data_offline_device(): - @contextmanager - def _patcher(): - with ( - patch( - "homeassistant.components.oncue.Oncue.async_login", - ), - patch( - "homeassistant.components.oncue.Oncue.async_fetch_all", - return_value=MOCK_ASYNC_FETCH_ALL_OFFLINE_DEVICE, - ), - ): - yield - - return _patcher() - - -def _patch_login_and_data_unavailable(): - @contextmanager - def _patcher(): - with ( - patch("homeassistant.components.oncue.Oncue.async_login"), - patch( - "homeassistant.components.oncue.Oncue.async_fetch_all", - return_value=MOCK_ASYNC_FETCH_ALL_UNAVAILABLE_DEVICE, - ), - ): - yield - - return _patcher() - - -def _patch_login_and_data_unavailable_device(): - @contextmanager - def _patcher(): - with ( - patch("homeassistant.components.oncue.Oncue.async_login"), - patch( - "homeassistant.components.oncue.Oncue.async_fetch_all", - return_value=MOCK_ASYNC_FETCH_ALL_UNAVAILABLE_DEVICE, - ), - ): - yield - - return _patcher() - - -def _patch_login_and_data_auth_failure(): - @contextmanager - def _patcher(): - with ( - patch( - "homeassistant.components.oncue.Oncue.async_login", - side_effect=LoginFailedException, - ), - patch( - "homeassistant.components.oncue.Oncue.async_fetch_all", - side_effect=LoginFailedException, - ), - ): - yield - - return _patcher() diff --git a/tests/components/oncue/test_binary_sensor.py b/tests/components/oncue/test_binary_sensor.py deleted file mode 100644 index d9fce699d39ea9..00000000000000 --- a/tests/components/oncue/test_binary_sensor.py +++ /dev/null @@ -1,58 +0,0 @@ -"""Tests for the oncue binary_sensor.""" - -from __future__ import annotations - -from homeassistant.components import oncue -from homeassistant.components.oncue.const import DOMAIN -from homeassistant.config_entries import ConfigEntryState -from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, STATE_OFF, STATE_ON -from homeassistant.core import HomeAssistant -from homeassistant.setup import async_setup_component - -from . import _patch_login_and_data, _patch_login_and_data_unavailable - -from tests.common import MockConfigEntry - - -async def test_binary_sensors(hass: HomeAssistant) -> None: - """Test that the binary sensors are setup with the expected values.""" - config_entry = MockConfigEntry( - domain=DOMAIN, - data={CONF_USERNAME: "any", CONF_PASSWORD: "any"}, - unique_id="any", - ) - config_entry.add_to_hass(hass) - with _patch_login_and_data(): - await async_setup_component(hass, oncue.DOMAIN, {oncue.DOMAIN: {}}) - await hass.async_block_till_done() - assert config_entry.state is ConfigEntryState.LOADED - - assert len(hass.states.async_all("binary_sensor")) == 1 - assert ( - hass.states.get( - "binary_sensor.my_generator_network_connection_established" - ).state - == STATE_ON - ) - - -async def test_binary_sensors_not_unavailable(hass: HomeAssistant) -> None: - """Test the network connection established binary sensor is available when connection status is false.""" - config_entry = MockConfigEntry( - domain=DOMAIN, - data={CONF_USERNAME: "any", CONF_PASSWORD: "any"}, - unique_id="any", - ) - config_entry.add_to_hass(hass) - with _patch_login_and_data_unavailable(): - await async_setup_component(hass, oncue.DOMAIN, {oncue.DOMAIN: {}}) - await hass.async_block_till_done() - assert config_entry.state is ConfigEntryState.LOADED - - assert len(hass.states.async_all("binary_sensor")) == 1 - assert ( - hass.states.get( - "binary_sensor.my_generator_network_connection_established" - ).state - == STATE_OFF - ) diff --git a/tests/components/oncue/test_config_flow.py b/tests/components/oncue/test_config_flow.py deleted file mode 100644 index 3907242e26c32c..00000000000000 --- a/tests/components/oncue/test_config_flow.py +++ /dev/null @@ -1,192 +0,0 @@ -"""Test the Oncue config flow.""" - -from unittest.mock import patch - -from aiooncue import LoginFailedException - -from homeassistant import config_entries -from homeassistant.components.oncue.const import DOMAIN -from homeassistant.const import CONF_PASSWORD, CONF_USERNAME -from homeassistant.core import HomeAssistant -from homeassistant.data_entry_flow import FlowResultType - -from tests.common import MockConfigEntry - - -async def test_form(hass: HomeAssistant) -> None: - """Test we get the form.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} - ) - assert result["type"] is FlowResultType.FORM - assert result["errors"] == {} - - with ( - patch("homeassistant.components.oncue.config_flow.Oncue.async_login"), - patch( - "homeassistant.components.oncue.async_setup_entry", - return_value=True, - ) as mock_setup_entry, - ): - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - { - "username": "TEST-username", - "password": "test-password", - }, - ) - await hass.async_block_till_done() - - assert result2["type"] is FlowResultType.CREATE_ENTRY - assert result2["title"] == "test-username" - assert result2["data"] == { - "username": "TEST-username", - "password": "test-password", - } - assert mock_setup_entry.call_count == 1 - - -async def test_form_invalid_auth(hass: HomeAssistant) -> None: - """Test we handle invalid auth.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} - ) - - with patch( - "homeassistant.components.oncue.config_flow.Oncue.async_login", - side_effect=LoginFailedException, - ): - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - { - "username": "test-username", - "password": "test-password", - }, - ) - - assert result2["type"] is FlowResultType.FORM - assert result2["errors"] == {CONF_PASSWORD: "invalid_auth"} - - -async def test_form_cannot_connect(hass: HomeAssistant) -> None: - """Test we handle cannot connect error.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} - ) - - with patch( - "homeassistant.components.oncue.config_flow.Oncue.async_login", - side_effect=TimeoutError, - ): - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - { - "username": "test-username", - "password": "test-password", - }, - ) - - assert result2["type"] is FlowResultType.FORM - assert result2["errors"] == {"base": "cannot_connect"} - - -async def test_form_unknown_exception(hass: HomeAssistant) -> None: - """Test we handle unknown exceptions.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} - ) - - with patch( - "homeassistant.components.oncue.config_flow.Oncue.async_login", - side_effect=Exception, - ): - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - { - "username": "test-username", - "password": "test-password", - }, - ) - - assert result2["type"] is FlowResultType.FORM - assert result2["errors"] == {"base": "unknown"} - - -async def test_already_configured(hass: HomeAssistant) -> None: - """Test already configured.""" - entry = MockConfigEntry( - domain=DOMAIN, - data={ - "username": "TEST-username", - "password": "test-password", - }, - unique_id="test-username", - ) - entry.add_to_hass(hass) - - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} - ) - - with patch("homeassistant.components.oncue.config_flow.Oncue.async_login"): - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - { - "username": "test-username", - "password": "test-password", - }, - ) - - assert result2["type"] is FlowResultType.ABORT - assert result2["reason"] == "already_configured" - - -async def test_reauth(hass: HomeAssistant) -> None: - """Test reauth flow.""" - config_entry = MockConfigEntry( - domain=DOMAIN, - data={ - CONF_USERNAME: "any", - CONF_PASSWORD: "old", - }, - ) - config_entry.add_to_hass(hass) - config_entry.async_start_reauth(hass) - await hass.async_block_till_done() - flows = hass.config_entries.flow.async_progress_by_handler(DOMAIN) - assert len(flows) == 1 - flow = flows[0] - - with patch( - "homeassistant.components.oncue.config_flow.Oncue.async_login", - side_effect=LoginFailedException, - ): - result2 = await hass.config_entries.flow.async_configure( - flow["flow_id"], - { - CONF_PASSWORD: "test-password", - }, - ) - - assert result2["type"] is FlowResultType.FORM - assert result2["errors"] == {"password": "invalid_auth"} - - with ( - patch("homeassistant.components.oncue.config_flow.Oncue.async_login"), - patch( - "homeassistant.components.oncue.async_setup_entry", - return_value=True, - ) as mock_setup_entry, - ): - result2 = await hass.config_entries.flow.async_configure( - flow["flow_id"], - { - CONF_PASSWORD: "test-password", - }, - ) - await hass.async_block_till_done() - - assert result2["type"] is FlowResultType.ABORT - assert result2["reason"] == "reauth_successful" - assert config_entry.data[CONF_PASSWORD] == "test-password" - assert mock_setup_entry.call_count == 1 diff --git a/tests/components/oncue/test_init.py b/tests/components/oncue/test_init.py index cf93b51dee1957..204f9eb9ecf4d1 100644 --- a/tests/components/oncue/test_init.py +++ b/tests/components/oncue/test_init.py @@ -1,94 +1,79 @@ -"""Tests for the oncue component.""" - -from __future__ import annotations - -from datetime import timedelta -from unittest.mock import patch - -from aiooncue import LoginFailedException - -from homeassistant.components import oncue -from homeassistant.components.oncue.const import DOMAIN -from homeassistant.config_entries import ConfigEntryState -from homeassistant.const import CONF_PASSWORD, CONF_USERNAME +"""Tests for the Oncue integration.""" + +from homeassistant.components.oncue import DOMAIN +from homeassistant.config_entries import ( + SOURCE_IGNORE, + ConfigEntryDisabler, + ConfigEntryState, +) from homeassistant.core import HomeAssistant -from homeassistant.setup import async_setup_component -from homeassistant.util import dt as dt_util +from homeassistant.helpers import issue_registry as ir -from . import _patch_login_and_data, _patch_login_and_data_auth_failure +from tests.common import MockConfigEntry -from tests.common import MockConfigEntry, async_fire_time_changed - -async def test_config_entry_reload(hass: HomeAssistant) -> None: - """Test that a config entry can be reloaded.""" - config_entry = MockConfigEntry( +async def test_oncue_repair_issue( + hass: HomeAssistant, issue_registry: ir.IssueRegistry +) -> None: + """Test the Oncue configuration entry loading/unloading handles the repair.""" + config_entry_1 = MockConfigEntry( + title="Example 1", domain=DOMAIN, - data={CONF_USERNAME: "any", CONF_PASSWORD: "any"}, - unique_id="any", ) - config_entry.add_to_hass(hass) - with _patch_login_and_data(): - await async_setup_component(hass, oncue.DOMAIN, {oncue.DOMAIN: {}}) - await hass.async_block_till_done() - assert config_entry.state is ConfigEntryState.LOADED - await hass.config_entries.async_unload(config_entry.entry_id) + config_entry_1.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry_1.entry_id) await hass.async_block_till_done() - assert config_entry.state is ConfigEntryState.NOT_LOADED - + assert config_entry_1.state is ConfigEntryState.LOADED -async def test_config_entry_login_error(hass: HomeAssistant) -> None: - """Test that a config entry is failed on login error.""" - config_entry = MockConfigEntry( + # Add a second one + config_entry_2 = MockConfigEntry( + title="Example 2", domain=DOMAIN, - data={CONF_USERNAME: "any", CONF_PASSWORD: "any"}, - unique_id="any", ) - config_entry.add_to_hass(hass) - with patch( - "homeassistant.components.oncue.Oncue.async_login", - side_effect=LoginFailedException, - ): - await async_setup_component(hass, oncue.DOMAIN, {oncue.DOMAIN: {}}) - await hass.async_block_till_done() - assert config_entry.state is ConfigEntryState.SETUP_ERROR + config_entry_2.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry_2.entry_id) + await hass.async_block_till_done() + assert config_entry_2.state is ConfigEntryState.LOADED + assert issue_registry.async_get_issue(DOMAIN, DOMAIN) -async def test_config_entry_retry_later(hass: HomeAssistant) -> None: - """Test that a config entry retry on connection error.""" - config_entry = MockConfigEntry( + # Add an ignored entry + config_entry_3 = MockConfigEntry( + source=SOURCE_IGNORE, domain=DOMAIN, - data={CONF_USERNAME: "any", CONF_PASSWORD: "any"}, - unique_id="any", ) - config_entry.add_to_hass(hass) - with patch( - "homeassistant.components.oncue.Oncue.async_login", - side_effect=TimeoutError, - ): - await async_setup_component(hass, oncue.DOMAIN, {oncue.DOMAIN: {}}) - await hass.async_block_till_done() - assert config_entry.state is ConfigEntryState.SETUP_RETRY + config_entry_3.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry_3.entry_id) + await hass.async_block_till_done() + assert config_entry_3.state is ConfigEntryState.NOT_LOADED -async def test_late_auth_failure(hass: HomeAssistant) -> None: - """Test auth fails after already setup.""" - config_entry = MockConfigEntry( + # Add a disabled entry + config_entry_4 = MockConfigEntry( + disabled_by=ConfigEntryDisabler.USER, domain=DOMAIN, - data={CONF_USERNAME: "any", CONF_PASSWORD: "any"}, - unique_id="any", ) - config_entry.add_to_hass(hass) - with _patch_login_and_data(): - await async_setup_component(hass, oncue.DOMAIN, {oncue.DOMAIN: {}}) - await hass.async_block_till_done() - assert config_entry.state is ConfigEntryState.LOADED + config_entry_4.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry_4.entry_id) + await hass.async_block_till_done() + + assert config_entry_4.state is ConfigEntryState.NOT_LOADED + + # Remove the first one + await hass.config_entries.async_remove(config_entry_1.entry_id) + await hass.async_block_till_done() + + assert config_entry_1.state is ConfigEntryState.NOT_LOADED + assert config_entry_2.state is ConfigEntryState.LOADED + assert issue_registry.async_get_issue(DOMAIN, DOMAIN) + + # Remove the second one + await hass.config_entries.async_remove(config_entry_2.entry_id) + await hass.async_block_till_done() - with _patch_login_and_data_auth_failure(): - async_fire_time_changed(hass, dt_util.utcnow() + timedelta(minutes=10)) - await hass.async_block_till_done() + assert config_entry_1.state is ConfigEntryState.NOT_LOADED + assert config_entry_2.state is ConfigEntryState.NOT_LOADED + assert issue_registry.async_get_issue(DOMAIN, DOMAIN) is None - flows = hass.config_entries.flow.async_progress_by_handler(DOMAIN) - assert len(flows) == 1 - flow = flows[0] - assert flow["context"]["source"] == "reauth" + # Check the ignored and disabled entries are removed + assert not hass.config_entries.async_entries(DOMAIN) diff --git a/tests/components/oncue/test_sensor.py b/tests/components/oncue/test_sensor.py deleted file mode 100644 index e5f55d540627fa..00000000000000 --- a/tests/components/oncue/test_sensor.py +++ /dev/null @@ -1,309 +0,0 @@ -"""Tests for the oncue sensor.""" - -from __future__ import annotations - -import pytest - -from homeassistant.components import oncue -from homeassistant.components.oncue.const import DOMAIN -from homeassistant.config_entries import ConfigEntryState -from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, STATE_UNAVAILABLE -from homeassistant.core import HomeAssistant -from homeassistant.helpers import device_registry as dr, entity_registry as er -from homeassistant.setup import async_setup_component - -from . import ( - _patch_login_and_data, - _patch_login_and_data_offline_device, - _patch_login_and_data_unavailable, - _patch_login_and_data_unavailable_device, -) - -from tests.common import MockConfigEntry - - -@pytest.mark.parametrize( - ("patcher", "connections"), - [ - (_patch_login_and_data, {("mac", "c9:24:22:6f:14:00")}), - (_patch_login_and_data_offline_device, set()), - ], -) -async def test_sensors( - hass: HomeAssistant, - device_registry: dr.DeviceRegistry, - entity_registry: er.EntityRegistry, - patcher, - connections, -) -> None: - """Test that the sensors are setup with the expected values.""" - config_entry = MockConfigEntry( - domain=DOMAIN, - data={CONF_USERNAME: "any", CONF_PASSWORD: "any"}, - unique_id="any", - ) - config_entry.add_to_hass(hass) - with patcher(): - await async_setup_component(hass, oncue.DOMAIN, {oncue.DOMAIN: {}}) - await hass.async_block_till_done() - assert config_entry.state is ConfigEntryState.LOADED - - ent = entity_registry.async_get("sensor.my_generator_latest_firmware") - dev = device_registry.async_get(ent.device_id) - assert dev.connections == connections - - assert len(hass.states.async_all("sensor")) == 25 - assert hass.states.get("sensor.my_generator_latest_firmware").state == "2.0.6" - - assert hass.states.get("sensor.my_generator_engine_speed").state == "0" - - assert hass.states.get("sensor.my_generator_engine_oil_pressure").state == "0" - - assert ( - hass.states.get("sensor.my_generator_engine_coolant_temperature").state == "0" - ) - - assert hass.states.get("sensor.my_generator_battery_voltage").state == "13.4" - - assert hass.states.get("sensor.my_generator_lube_oil_temperature").state == "0" - - assert ( - hass.states.get("sensor.my_generator_generator_controller_temperature").state - == "29.0" - ) - - assert ( - hass.states.get("sensor.my_generator_engine_compartment_temperature").state - == "17.0" - ) - - assert ( - hass.states.get("sensor.my_generator_generator_true_total_power").state == "0.0" - ) - - assert ( - hass.states.get( - "sensor.my_generator_generator_true_percent_of_rated_power" - ).state - == "0" - ) - - assert ( - hass.states.get( - "sensor.my_generator_generator_voltage_average_line_to_line" - ).state - == "0.0" - ) - - assert hass.states.get("sensor.my_generator_generator_frequency").state == "0.0" - - assert hass.states.get("sensor.my_generator_generator_state").state == "Off" - - assert ( - hass.states.get( - "sensor.my_generator_generator_controller_total_operation_time" - ).state - == "16770.8" - ) - - assert hass.states.get("sensor.my_generator_engine_total_run_time").state == "28.1" - - assert ( - hass.states.get("sensor.my_generator_ats_contactor_position").state == "Source1" - ) - - assert hass.states.get("sensor.my_generator_ip_address").state == "1.2.3.4:1026" - - assert ( - hass.states.get("sensor.my_generator_connected_server_ip_address").state - == "40.117.195.28" - ) - - assert hass.states.get("sensor.my_generator_engine_target_speed").state == "0" - - assert ( - hass.states.get("sensor.my_generator_engine_total_run_time_loaded").state - == "5.5" - ) - - assert ( - hass.states.get( - "sensor.my_generator_source1_voltage_average_line_to_line" - ).state - == "253.5" - ) - - assert ( - hass.states.get( - "sensor.my_generator_source2_voltage_average_line_to_line" - ).state - == "0.0" - ) - - assert ( - hass.states.get("sensor.my_generator_genset_total_energy").state - == "1.2022309E7" - ) - assert ( - hass.states.get("sensor.my_generator_engine_total_number_of_starts").state - == "101" - ) - assert ( - hass.states.get("sensor.my_generator_generator_current_average").state == "0.0" - ) - - -@pytest.mark.parametrize( - ("patcher", "connections"), - [ - (_patch_login_and_data_unavailable_device, set()), - (_patch_login_and_data_unavailable, {("mac", "c9:24:22:6f:14:00")}), - ], -) -async def test_sensors_unavailable(hass: HomeAssistant, patcher, connections) -> None: - """Test that the sensors are unavailable.""" - config_entry = MockConfigEntry( - domain=DOMAIN, - data={CONF_USERNAME: "any", CONF_PASSWORD: "any"}, - unique_id="any", - ) - config_entry.add_to_hass(hass) - with patcher(): - await async_setup_component(hass, oncue.DOMAIN, {oncue.DOMAIN: {}}) - await hass.async_block_till_done() - assert config_entry.state is ConfigEntryState.LOADED - - assert len(hass.states.async_all("sensor")) == 25 - assert ( - hass.states.get("sensor.my_generator_latest_firmware").state - == STATE_UNAVAILABLE - ) - - assert ( - hass.states.get("sensor.my_generator_engine_speed").state == STATE_UNAVAILABLE - ) - - assert ( - hass.states.get("sensor.my_generator_engine_oil_pressure").state - == STATE_UNAVAILABLE - ) - - assert ( - hass.states.get("sensor.my_generator_engine_coolant_temperature").state - == STATE_UNAVAILABLE - ) - - assert ( - hass.states.get("sensor.my_generator_battery_voltage").state - == STATE_UNAVAILABLE - ) - - assert ( - hass.states.get("sensor.my_generator_lube_oil_temperature").state - == STATE_UNAVAILABLE - ) - - assert ( - hass.states.get("sensor.my_generator_generator_controller_temperature").state - == STATE_UNAVAILABLE - ) - - assert ( - hass.states.get("sensor.my_generator_engine_compartment_temperature").state - == STATE_UNAVAILABLE - ) - - assert ( - hass.states.get("sensor.my_generator_generator_true_total_power").state - == STATE_UNAVAILABLE - ) - - assert ( - hass.states.get( - "sensor.my_generator_generator_true_percent_of_rated_power" - ).state - == STATE_UNAVAILABLE - ) - - assert ( - hass.states.get( - "sensor.my_generator_generator_voltage_average_line_to_line" - ).state - == STATE_UNAVAILABLE - ) - - assert ( - hass.states.get("sensor.my_generator_generator_frequency").state - == STATE_UNAVAILABLE - ) - - assert ( - hass.states.get("sensor.my_generator_generator_state").state - == STATE_UNAVAILABLE - ) - - assert ( - hass.states.get( - "sensor.my_generator_generator_controller_total_operation_time" - ).state - == STATE_UNAVAILABLE - ) - - assert ( - hass.states.get("sensor.my_generator_engine_total_run_time").state - == STATE_UNAVAILABLE - ) - - assert ( - hass.states.get("sensor.my_generator_ats_contactor_position").state - == STATE_UNAVAILABLE - ) - - assert hass.states.get("sensor.my_generator_ip_address").state == STATE_UNAVAILABLE - - assert ( - hass.states.get("sensor.my_generator_connected_server_ip_address").state - == STATE_UNAVAILABLE - ) - - assert ( - hass.states.get("sensor.my_generator_engine_target_speed").state - == STATE_UNAVAILABLE - ) - - assert ( - hass.states.get("sensor.my_generator_engine_total_run_time_loaded").state - == STATE_UNAVAILABLE - ) - - assert ( - hass.states.get( - "sensor.my_generator_source1_voltage_average_line_to_line" - ).state - == STATE_UNAVAILABLE - ) - - assert ( - hass.states.get( - "sensor.my_generator_source2_voltage_average_line_to_line" - ).state - == STATE_UNAVAILABLE - ) - - assert ( - hass.states.get("sensor.my_generator_genset_total_energy").state - == STATE_UNAVAILABLE - ) - assert ( - hass.states.get("sensor.my_generator_engine_total_number_of_starts").state - == STATE_UNAVAILABLE - ) - assert ( - hass.states.get("sensor.my_generator_generator_current_average").state - == STATE_UNAVAILABLE - ) - - assert ( - hass.states.get("sensor.my_generator_battery_voltage").state - == STATE_UNAVAILABLE - ) From 8fafbfaf82b4257b803c5c1e69e9f4aec8fc9878 Mon Sep 17 00:00:00 2001 From: Manu <4445816+tr4nt0r@users.noreply.github.com> Date: Wed, 30 Apr 2025 13:07:51 +0200 Subject: [PATCH 09/37] Change function alias to proxy in ista EcoTrend (#143911) Change function alias --- homeassistant/components/ista_ecotrend/config_flow.py | 6 +++++- homeassistant/components/ista_ecotrend/quality_scale.yaml | 2 +- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/ista_ecotrend/config_flow.py b/homeassistant/components/ista_ecotrend/config_flow.py index 1ca7f7c329ab87..ee69e52e580617 100644 --- a/homeassistant/components/ista_ecotrend/config_flow.py +++ b/homeassistant/components/ista_ecotrend/config_flow.py @@ -146,4 +146,8 @@ def get_consumption_units() -> set[str]: errors=errors, ) - async_step_reconfigure = async_step_reauth_confirm + async def async_step_reconfigure( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Reconfigure flow for ista EcoTrend integration.""" + return await self.async_step_reauth_confirm(user_input) diff --git a/homeassistant/components/ista_ecotrend/quality_scale.yaml b/homeassistant/components/ista_ecotrend/quality_scale.yaml index 33cf24592b376d..a06aef7297fa77 100644 --- a/homeassistant/components/ista_ecotrend/quality_scale.yaml +++ b/homeassistant/components/ista_ecotrend/quality_scale.yaml @@ -66,7 +66,7 @@ rules: entity-translations: done exception-translations: done icon-translations: done - reconfiguration-flow: todo + reconfiguration-flow: done repair-issues: todo stale-devices: todo From e24082be9aa38efb19808f204b4c0e9f3202cf0c Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 30 Apr 2025 13:31:21 +0200 Subject: [PATCH 10/37] Fix incorrect return types in samsungtv tests (#143937) --- tests/components/samsungtv/conftest.py | 30 +++++++++++++------------- 1 file changed, 15 insertions(+), 15 deletions(-) diff --git a/tests/components/samsungtv/conftest.py b/tests/components/samsungtv/conftest.py index 105ef0f25ad738..f5ae787ab26900 100644 --- a/tests/components/samsungtv/conftest.py +++ b/tests/components/samsungtv/conftest.py @@ -53,7 +53,7 @@ def silent_ssdp_scanner() -> Generator[None]: @pytest.fixture(autouse=True) -def samsungtv_mock_async_get_local_ip(): +def samsungtv_mock_async_get_local_ip() -> Generator[None]: """Mock upnp util's async_get_local_ip.""" with patch( "homeassistant.components.samsungtv.media_player.async_get_local_ip", @@ -63,7 +63,7 @@ def samsungtv_mock_async_get_local_ip(): @pytest.fixture(autouse=True) -def fake_host_fixture() -> None: +def fake_host_fixture() -> Generator[None]: """Patch gethostbyname.""" with patch( "homeassistant.components.samsungtv.config_flow.socket.gethostbyname", @@ -73,14 +73,14 @@ def fake_host_fixture() -> None: @pytest.fixture(autouse=True) -def app_list_delay_fixture() -> None: +def app_list_delay_fixture() -> Generator[None]: """Patch APP_LIST_DELAY.""" with patch("homeassistant.components.samsungtv.media_player.APP_LIST_DELAY", 0): yield @pytest.fixture(name="upnp_factory", autouse=True) -def upnp_factory_fixture() -> Mock: +def upnp_factory_fixture() -> Generator[Mock]: """Patch UpnpFactory.""" with patch( "homeassistant.components.samsungtv.media_player.UpnpFactory", @@ -92,7 +92,7 @@ def upnp_factory_fixture() -> Mock: @pytest.fixture(name="upnp_device") -async def upnp_device_fixture(upnp_factory: Mock) -> Mock: +def upnp_device_fixture(upnp_factory: Mock) -> Generator[Mock]: """Patch async_upnp_client.""" upnp_device = Mock(UpnpDevice) upnp_device.services = {} @@ -102,7 +102,7 @@ async def upnp_device_fixture(upnp_factory: Mock) -> Mock: @pytest.fixture(name="dmr_device") -async def dmr_device_fixture(upnp_device: Mock) -> Mock: +def dmr_device_fixture(upnp_device: Mock) -> Generator[Mock]: """Patch async_upnp_client.""" with patch( "homeassistant.components.samsungtv.media_player.DmrDevice", @@ -137,7 +137,7 @@ def _async_unsubscribe_services(): @pytest.fixture(name="upnp_notify_server") -async def upnp_notify_server_fixture(upnp_factory: Mock) -> Mock: +def upnp_notify_server_fixture(upnp_factory: Mock) -> Generator[Mock]: """Patch async_upnp_client.""" with patch( "homeassistant.components.samsungtv.media_player.AiohttpNotifyServer", @@ -149,7 +149,7 @@ async def upnp_notify_server_fixture(upnp_factory: Mock) -> Mock: @pytest.fixture(name="remote") -def remote_fixture() -> Mock: +def remote_fixture() -> Generator[Mock]: """Patch the samsungctl Remote.""" with patch("homeassistant.components.samsungtv.bridge.Remote") as remote_class: remote = Mock(Remote) @@ -160,7 +160,7 @@ def remote_fixture() -> Mock: @pytest.fixture(name="rest_api") -def rest_api_fixture() -> Mock: +def rest_api_fixture() -> Generator[Mock]: """Patch the samsungtvws SamsungTVAsyncRest.""" with patch( "homeassistant.components.samsungtv.bridge.SamsungTVAsyncRest", @@ -173,7 +173,7 @@ def rest_api_fixture() -> Mock: @pytest.fixture(name="rest_api_non_ssl_only") -def rest_api_fixture_non_ssl_only() -> Mock: +def rest_api_fixture_non_ssl_only() -> Generator[None]: """Patch the samsungtvws SamsungTVAsyncRest non-ssl only.""" class MockSamsungTVAsyncRest: @@ -198,7 +198,7 @@ async def rest_device_info(self): @pytest.fixture(name="rest_api_failing") -def rest_api_failure_fixture() -> Mock: +def rest_api_failure_fixture() -> Generator[None]: """Patch the samsungtvws SamsungTVAsyncRest.""" with patch( "homeassistant.components.samsungtv.bridge.SamsungTVAsyncRest", @@ -209,7 +209,7 @@ def rest_api_failure_fixture() -> Mock: @pytest.fixture(name="remoteencws_failing") -def remoteencws_failing_fixture(): +def remoteencws_failing_fixture() -> Generator[None]: """Patch the samsungtvws SamsungTVEncryptedWSAsyncRemote.""" with patch( "homeassistant.components.samsungtv.bridge.SamsungTVEncryptedWSAsyncRemote.start_listening", @@ -219,7 +219,7 @@ def remoteencws_failing_fixture(): @pytest.fixture(name="remotews") -def remotews_fixture() -> Mock: +def remotews_fixture() -> Generator[Mock]: """Patch the samsungtvws SamsungTVWS.""" remotews = Mock(SamsungTVWSAsyncRemote) remotews.__aenter__ = AsyncMock(return_value=remotews) @@ -260,7 +260,7 @@ def _mock_ws_event_callback(event: str, response: Any): @pytest.fixture(name="remoteencws") -def remoteencws_fixture() -> Mock: +def remoteencws_fixture() -> Generator[Mock]: """Patch the samsungtvws SamsungTVEncryptedWSAsyncRemote.""" remoteencws = Mock(SamsungTVEncryptedWSAsyncRemote) remoteencws.__aenter__ = AsyncMock(return_value=remoteencws) @@ -292,7 +292,7 @@ def mock_now() -> datetime: @pytest.fixture(name="mac_address", autouse=True) -def mac_address_fixture() -> Mock: +def mac_address_fixture() -> Generator[Mock]: """Patch getmac.get_mac_address.""" with patch("getmac.get_mac_address", return_value=None) as mac: yield mac From ae118da5a1cb809b3082f2b77bb386b650a852d9 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 30 Apr 2025 14:03:38 +0200 Subject: [PATCH 11/37] Bump orjson to 3.10.18 (#143943) --- 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 928e4e95b87c47..ce943f2b712333 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -46,7 +46,7 @@ Jinja2==3.1.6 lru-dict==1.3.0 mutagen==1.47.0 numpy==2.2.2 -orjson==3.10.16 +orjson==3.10.18 packaging>=23.1 paho-mqtt==2.1.0 Pillow==11.2.1 diff --git a/pyproject.toml b/pyproject.toml index 43ca7cf527407b..9315e2c7e8995e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -86,7 +86,7 @@ dependencies = [ "Pillow==11.2.1", "propcache==0.3.1", "pyOpenSSL==25.0.0", - "orjson==3.10.16", + "orjson==3.10.18", "packaging>=23.1", "psutil-home-assistant==0.0.1", # pymicro_vad is indirectly imported from onboarding via the import chain diff --git a/requirements.txt b/requirements.txt index 5eba886d0c0fc2..45af8b647de09a 100644 --- a/requirements.txt +++ b/requirements.txt @@ -38,7 +38,7 @@ cryptography==44.0.1 Pillow==11.2.1 propcache==0.3.1 pyOpenSSL==25.0.0 -orjson==3.10.16 +orjson==3.10.18 packaging>=23.1 psutil-home-assistant==0.0.1 pymicro-vad==1.0.1 From 5dab9ba01ba76f259cd39fe40dca65c649499ce5 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Wed, 30 Apr 2025 08:21:19 -0400 Subject: [PATCH 12/37] Allow streaming text into TTS ResultStream objects (#143745) Allow streaming messages into TTS ResultStream --- homeassistant/components/tts/__init__.py | 50 +++++++++++++++++++++++- tests/components/tts/test_init.py | 28 +++++++++++++ 2 files changed, 77 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/tts/__init__.py b/homeassistant/components/tts/__init__.py index 22c388cae9f88e..44badaa73d22e8 100644 --- a/homeassistant/components/tts/__init__.py +++ b/homeassistant/components/tts/__init__.py @@ -42,7 +42,7 @@ from homeassistant.helpers.event import async_call_later from homeassistant.helpers.network import get_url from homeassistant.helpers.typing import UNDEFINED, ConfigType -from homeassistant.util import language as language_util +from homeassistant.util import language as language_util, ulid as ulid_util from .const import ( ATTR_CACHE, @@ -495,6 +495,18 @@ def async_set_message(self, message: str) -> None: ) ) + @callback + def async_set_message_stream(self, message_stream: AsyncGenerator[str]) -> None: + """Set a stream that will generate the message.""" + self._result_cache.set_result( + self._manager.async_cache_message_stream_in_memory( + engine=self.engine, + message_stream=message_stream, + language=self.language, + options=self.options, + ) + ) + async def async_stream_result(self) -> AsyncGenerator[bytes]: """Get the stream of this result.""" cache = await self._result_cache @@ -735,6 +747,42 @@ def async_create_result_stream( self.token_to_stream_cleanup.schedule() return result_stream + @callback + def async_cache_message_stream_in_memory( + self, + engine: str, + message_stream: AsyncGenerator[str], + language: str, + options: dict, + ) -> TTSCache: + """Make sure a message stream will be cached in memory and returns cache object. + + Requires options, language to be processed. + """ + if (engine_instance := get_engine_instance(self.hass, engine)) is None: + raise HomeAssistantError(f"Provider {engine} not found") + + cache_key = ulid_util.ulid_now() + extension = options.get(ATTR_PREFERRED_FORMAT, _DEFAULT_FORMAT) + data_gen = self._async_generate_tts_audio( + engine_instance, message_stream, language, options + ) + + cache = TTSCache( + cache_key=cache_key, + extension=extension, + data_gen=data_gen, + ) + self.mem_cache[cache_key] = cache + self.hass.async_create_background_task( + self._load_data_into_cache( + cache, engine_instance, "[Streaming TTS]", False, language, options + ), + f"tts_load_data_into_cache_{engine_instance.name}", + ) + self.memcache_cleanup.schedule() + return cache + @callback def async_cache_message_in_memory( self, diff --git a/tests/components/tts/test_init.py b/tests/components/tts/test_init.py index 99f4b008c68b5b..45424be8481eed 100644 --- a/tests/components/tts/test_init.py +++ b/tests/components/tts/test_init.py @@ -1842,6 +1842,7 @@ async def test_default_engine_prefer_cloud_entity( async def test_stream(hass: HomeAssistant, mock_tts_entity: MockTTSEntity) -> None: """Test creating streams.""" await mock_config_entry_setup(hass, mock_tts_entity) + stream = tts.async_create_stream(hass, mock_tts_entity.entity_id) assert stream.language == mock_tts_entity.default_language assert stream.options == (mock_tts_entity.default_options or {}) @@ -1850,6 +1851,33 @@ async def test_stream(hass: HomeAssistant, mock_tts_entity: MockTTSEntity) -> No result_data = b"".join([chunk async for chunk in stream.async_stream_result()]) assert result_data == MOCK_DATA + async def async_stream_tts_audio( + request: tts.TTSAudioRequest, + ) -> tts.TTSAudioResponse: + """Mock stream TTS audio.""" + + async def gen_data(): + async for msg in request.message_gen: + yield msg.encode() + + return tts.TTSAudioResponse( + extension="mp3", + data_gen=gen_data(), + ) + + mock_tts_entity.async_stream_tts_audio = async_stream_tts_audio + + async def stream_message(): + """Mock stream message.""" + yield "he" + yield "ll" + yield "o" + + stream = tts.async_create_stream(hass, mock_tts_entity.entity_id) + stream.async_set_message_stream(stream_message()) + result_data = b"".join([chunk async for chunk in stream.async_stream_result()]) + assert result_data == b"hello" + data = b"beer" stream2 = MockResultStream(hass, "wav", data) assert tts.async_get_stream(hass, stream2.token) is stream2 From d924f0b1d638b88b866c9efb3e7740cdd0e06b52 Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Wed, 30 Apr 2025 05:47:54 -0700 Subject: [PATCH 13/37] Improve the live context tool prompt with additional instructions (#143746) * Improve the live context tool prompt with additional instructions * Fix vertical whitespace --- homeassistant/helpers/llm.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/helpers/llm.py b/homeassistant/helpers/llm.py index 3e521aa7ef1f57..27554330eebdd8 100644 --- a/homeassistant/helpers/llm.py +++ b/homeassistant/helpers/llm.py @@ -1034,10 +1034,10 @@ class GetLiveContextTool(Tool): name = "GetLiveContext" description = ( - "Use this tool when the user asks a question about the CURRENT state, " - "value, or mode of a specific device, sensor, entity, or area in the " - "smart home, and the answer can be improved with real-time data not " - "available in the static device overview list. " + "Provides real-time information about the CURRENT state, value, or mode of devices, sensors, entities, or areas. " + "Use this tool for: " + "1. Answering questions about current conditions (e.g., 'Is the light on?'). " + "2. As the first step in conditional actions (e.g., 'If the weather is rainy, turn off sprinklers' requires checking the weather first)." ) async def async_call( From bdd90992947448997bb5a64a61f01c979109c353 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Manuel=20R=C3=BCger?= Date: Wed, 30 Apr 2025 14:48:18 +0200 Subject: [PATCH 14/37] switchbot_cloud: Add firmware information (#143693) --- homeassistant/components/switchbot_cloud/entity.py | 4 ++++ tests/components/switchbot_cloud/test_button.py | 2 ++ tests/components/switchbot_cloud/test_init.py | 6 ++++++ tests/components/switchbot_cloud/test_lock.py | 1 + tests/components/switchbot_cloud/test_sensor.py | 2 ++ tests/components/switchbot_cloud/test_switch.py | 3 +++ 6 files changed, 18 insertions(+) diff --git a/homeassistant/components/switchbot_cloud/entity.py b/homeassistant/components/switchbot_cloud/entity.py index 74adcb049c1f91..5eb96ed3ac8af7 100644 --- a/homeassistant/components/switchbot_cloud/entity.py +++ b/homeassistant/components/switchbot_cloud/entity.py @@ -29,11 +29,15 @@ def __init__( super().__init__(coordinator) self._api = api self._attr_unique_id = device.device_id + _sw_version = None + if self.coordinator.data is not None: + _sw_version = self.coordinator.data.get("version") self._attr_device_info = DeviceInfo( identifiers={(DOMAIN, device.device_id)}, name=device.device_name, manufacturer="SwitchBot", model=device.device_type, + sw_version=_sw_version, ) async def send_api_command( diff --git a/tests/components/switchbot_cloud/test_button.py b/tests/components/switchbot_cloud/test_button.py index 0779e54ee03872..8c74709fdf589c 100644 --- a/tests/components/switchbot_cloud/test_button.py +++ b/tests/components/switchbot_cloud/test_button.py @@ -19,6 +19,7 @@ async def test_pressmode_bot( """Test press.""" mock_list_devices.return_value = [ Device( + version="V1.0", deviceId="bot-id-1", deviceName="bot-1", deviceType="Bot", @@ -51,6 +52,7 @@ async def test_switchmode_bot_no_button_entity( """Test a switchMode bot isn't added as a button.""" mock_list_devices.return_value = [ Device( + version="V1.0", deviceId="bot-id-1", deviceName="bot-1", deviceType="Bot", diff --git a/tests/components/switchbot_cloud/test_init.py b/tests/components/switchbot_cloud/test_init.py index f4837c4e97e327..b2d1cff66795af 100644 --- a/tests/components/switchbot_cloud/test_init.py +++ b/tests/components/switchbot_cloud/test_init.py @@ -33,30 +33,35 @@ async def test_setup_entry_success( """Test successful setup of entry.""" mock_list_devices.return_value = [ Remote( + version="V1.0", deviceId="air-conditonner-id-1", deviceName="air-conditonner-name-1", remoteType="Air Conditioner", hubDeviceId="test-hub-id", ), Device( + version="V1.0", deviceId="plug-id-1", deviceName="plug-name-1", deviceType="Plug", hubDeviceId="test-hub-id", ), Remote( + version="V1.0", deviceId="plug-id-2", deviceName="plug-name-2", remoteType="DIY Plug", hubDeviceId="test-hub-id", ), Remote( + version="V1.0", deviceId="meter-pro-1", deviceName="meter-pro-name-1", deviceType="MeterPro(CO2)", hubDeviceId="test-hub-id", ), Remote( + version="V1.0", deviceId="hub2-1", deviceName="hub2-name-1", deviceType="Hub 2", @@ -104,6 +109,7 @@ async def test_setup_entry_fails_when_refreshing( """Test error handling in get_status in setup of entry.""" mock_list_devices.return_value = [ Device( + version="V1.0", deviceId="test-id", deviceName="test-name", deviceType="Plug", diff --git a/tests/components/switchbot_cloud/test_lock.py b/tests/components/switchbot_cloud/test_lock.py index fcb81abfc51b3f..ca41f6eb99f16d 100644 --- a/tests/components/switchbot_cloud/test_lock.py +++ b/tests/components/switchbot_cloud/test_lock.py @@ -17,6 +17,7 @@ async def test_lock(hass: HomeAssistant, mock_list_devices, mock_get_status) -> """Test locking and unlocking.""" mock_list_devices.return_value = [ Device( + version="V1.0", deviceId="lock-id-1", deviceName="lock-1", deviceType="Smart Lock", diff --git a/tests/components/switchbot_cloud/test_sensor.py b/tests/components/switchbot_cloud/test_sensor.py index 6b0a52800f3fba..1008dd72b47f7f 100644 --- a/tests/components/switchbot_cloud/test_sensor.py +++ b/tests/components/switchbot_cloud/test_sensor.py @@ -26,6 +26,7 @@ async def test_meter( mock_list_devices.return_value = [ Device( + version="V1.0", deviceId="meter-id-1", deviceName="meter-1", deviceType="Meter", @@ -50,6 +51,7 @@ async def test_meter_no_coordinator_data( """Test meter sensors are unknown without coordinator data.""" mock_list_devices.return_value = [ Device( + version="V1.0", deviceId="meter-id-1", deviceName="meter-1", deviceType="Meter", diff --git a/tests/components/switchbot_cloud/test_switch.py b/tests/components/switchbot_cloud/test_switch.py index 99e0f50aa532ef..9bd93342baecc0 100644 --- a/tests/components/switchbot_cloud/test_switch.py +++ b/tests/components/switchbot_cloud/test_switch.py @@ -25,6 +25,7 @@ async def test_relay_switch( """Test turn on and turn off.""" mock_list_devices.return_value = [ Device( + version="V1.0", deviceId="relay-switch-id-1", deviceName="relay-switch-1", deviceType="Relay Switch 1", @@ -59,6 +60,7 @@ async def test_switchmode_bot( """Test turn on and turn off.""" mock_list_devices.return_value = [ Device( + version="V1.0", deviceId="bot-id-1", deviceName="bot-1", deviceType="Bot", @@ -93,6 +95,7 @@ async def test_pressmode_bot_no_switch_entity( """Test a pressMode bot isn't added as a switch.""" mock_list_devices.return_value = [ Device( + version="V1.0", deviceId="bot-id-1", deviceName="bot-1", deviceType="Bot", From b16151ac6d8ebc94c3be85787b56d8c8ec01ab1f Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Wed, 30 Apr 2025 05:49:33 -0700 Subject: [PATCH 15/37] Add an LLM tool for fetching todo list items (#143777) * Add a tool for fetching todo list items * Simplify the todo list interface by adding an "all" status * Update prompt to improve performance on smaller models --- homeassistant/components/todo/__init__.py | 15 ++- homeassistant/helpers/llm.py | 68 +++++++++++++ tests/helpers/test_llm.py | 114 +++++++++++++++++++++- 3 files changed, 188 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/todo/__init__.py b/homeassistant/components/todo/__init__.py index b8c90f917d4e3b..ea0448b74997d8 100644 --- a/homeassistant/components/todo/__init__.py +++ b/homeassistant/components/todo/__init__.py @@ -95,6 +95,12 @@ class TodoItemFieldDescription: vol.Optional(desc.service_field): desc.validation for desc in TODO_ITEM_FIELDS } TODO_ITEM_FIELD_VALIDATIONS = [cv.has_at_most_one_key(ATTR_DUE_DATE, ATTR_DUE_DATETIME)] +TODO_SERVICE_GET_ITEMS_SCHEMA = { + vol.Optional(ATTR_STATUS): vol.All( + cv.ensure_list, + [vol.In({TodoItemStatus.NEEDS_ACTION, TodoItemStatus.COMPLETED})], + ), +} def _validate_supported_features( @@ -177,14 +183,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: ) component.async_register_entity_service( TodoServices.GET_ITEMS, - cv.make_entity_service_schema( - { - vol.Optional(ATTR_STATUS): vol.All( - cv.ensure_list, - [vol.In({TodoItemStatus.NEEDS_ACTION, TodoItemStatus.COMPLETED})], - ), - } - ), + cv.make_entity_service_schema(TODO_SERVICE_GET_ITEMS_SCHEMA), _async_get_todo_items, supports_response=SupportsResponse.ONLY, ) diff --git a/homeassistant/helpers/llm.py b/homeassistant/helpers/llm.py index 27554330eebdd8..adf113e0f3036d 100644 --- a/homeassistant/helpers/llm.py +++ b/homeassistant/helpers/llm.py @@ -24,6 +24,7 @@ from homeassistant.components.homeassistant import async_should_expose from homeassistant.components.intent import async_device_supports_timers from homeassistant.components.script import DOMAIN as SCRIPT_DOMAIN +from homeassistant.components.todo import DOMAIN as TODO_DOMAIN, TodoServices from homeassistant.components.weather import INTENT_GET_WEATHER from homeassistant.const import ( ATTR_DOMAIN, @@ -577,6 +578,14 @@ def _async_get_tools( names.extend(info["names"].split(", ")) tools.append(CalendarGetEventsTool(names)) + if exposed_domains is not None and TODO_DOMAIN in exposed_domains: + names = [] + for info in exposed_entities["entities"].values(): + if info["domain"] != TODO_DOMAIN: + continue + names.extend(info["names"].split(", ")) + tools.append(TodoGetItemsTool(names)) + tools.extend( ScriptTool(self.hass, script_entity_id) for script_entity_id in exposed_entities[SCRIPT_DOMAIN] @@ -1024,6 +1033,65 @@ async def async_call( return {"success": True, "result": events} +class TodoGetItemsTool(Tool): + """LLM Tool allowing querying a to-do list.""" + + name = "todo_get_items" + description = ( + "Query a to-do list to find out what items are on it. " + "Use this to answer questions like 'What's on my task list?' or 'Read my grocery list'. " + "Filters items by status (needs_action, completed, all)." + ) + + def __init__(self, todo_lists: list[str]) -> None: + """Init the get items tool.""" + self.parameters = vol.Schema( + { + vol.Required("todo_list"): vol.In(todo_lists), + vol.Optional( + "status", + description="Filter returned items by status, by default returns incomplete items", + default="needs_action", + ): vol.In(["needs_action", "completed", "all"]), + } + ) + + async def async_call( + self, hass: HomeAssistant, tool_input: ToolInput, llm_context: LLMContext + ) -> JsonObjectType: + """Query a to-do list.""" + data = self.parameters(tool_input.tool_args) + result = intent.async_match_targets( + hass, + intent.MatchTargetsConstraints( + name=data["todo_list"], + domains=[TODO_DOMAIN], + assistant=llm_context.assistant, + ), + ) + if not result.is_match: + return {"success": False, "error": "To-do list not found"} + entity_id = result.states[0].entity_id + service_data: dict[str, Any] = {"entity_id": entity_id} + if status := data.get("status"): + if status == "all": + service_data["status"] = ["needs_action", "completed"] + else: + service_data["status"] = [status] + service_result = await hass.services.async_call( + TODO_DOMAIN, + TodoServices.GET_ITEMS, + service_data, + context=llm_context.context, + blocking=True, + return_response=True, + ) + if not service_result: + return {"success": False, "error": "To-do list not found"} + items = cast(dict, service_result)[entity_id]["items"] + return {"success": True, "result": items} + + class GetLiveContextTool(Tool): """Tool for getting the current state of exposed entities. diff --git a/tests/helpers/test_llm.py b/tests/helpers/test_llm.py index 145618cbeab840..1a9225c505bb5e 100644 --- a/tests/helpers/test_llm.py +++ b/tests/helpers/test_llm.py @@ -7,7 +7,7 @@ import pytest import voluptuous as vol -from homeassistant.components import calendar +from homeassistant.components import calendar, todo from homeassistant.components.homeassistant.exposed_entities import async_expose_entity from homeassistant.components.intent import async_register_timer_handler from homeassistant.components.script.config import ScriptConfig @@ -1332,6 +1332,118 @@ async def test_calendar_get_events_tool(hass: HomeAssistant) -> None: } +async def test_todo_get_items_tool(hass: HomeAssistant) -> None: + """Test the todo get items tool.""" + assert await async_setup_component(hass, "homeassistant", {}) + assert await async_setup_component(hass, "todo", {}) + hass.states.async_set( + "todo.test_list", "0", {"friendly_name": "Mock Todo List Name"} + ) + async_expose_entity(hass, "conversation", "todo.test_list", True) + context = Context() + llm_context = llm.LLMContext( + platform="test_platform", + context=context, + user_prompt="test_text", + language="*", + assistant="conversation", + device_id=None, + ) + api = await llm.async_get_api(hass, "assist", llm_context) + tool = next((tool for tool in api.tools if tool.name == "todo_get_items"), None) + assert tool is not None + assert tool.parameters.schema["todo_list"].container == ["Mock Todo List Name"] + + calls = async_mock_service( + hass, + domain=todo.DOMAIN, + service=todo.TodoServices.GET_ITEMS, + schema=cv.make_entity_service_schema(todo.TODO_SERVICE_GET_ITEMS_SCHEMA), + response={ + "todo.test_list": { + "items": [ + { + "uid": "1234", + "summary": "Buy milk", + "status": "needs_action", + }, + { + "uid": "5678", + "summary": "Call mom", + "status": "needs_action", + "due": "2025-09-17", + "description": "Remember birthday", + }, + ] + } + }, + ) + + # Test without status filter (defaults to needs_action) + result = await tool.async_call( + hass, + llm.ToolInput("todo_get_items", {"todo_list": "Mock Todo List Name"}), + llm_context, + ) + + assert len(calls) == 1 + assert calls[0].data == { + "entity_id": ["todo.test_list"], + "status": ["needs_action"], + } + assert result == { + "success": True, + "result": [ + { + "uid": "1234", + "status": "needs_action", + "summary": "Buy milk", + }, + { + "uid": "5678", + "status": "needs_action", + "summary": "Call mom", + "due": "2025-09-17", + "description": "Remember birthday", + }, + ], + } + + # Test that the status filter is passed correctly to the service call. + # We don't assert on the response since it is fixed above. + calls.clear() + result = await tool.async_call( + hass, + llm.ToolInput( + "todo_get_items", + {"todo_list": "Mock Todo List Name", "status": "completed"}, + ), + llm_context, + ) + assert len(calls) == 1 + assert calls[0].data == { + "entity_id": ["todo.test_list"], + "status": ["completed"], + } + + # Test that the status filter is passed correctly to the service call. + # We don't assert on the response since it is fixed above. + calls.clear() + result = await tool.async_call( + hass, + llm.ToolInput( + "todo_get_items", + {"todo_list": "Mock Todo List Name", "status": "all"}, + ), + llm_context, + ) + assert len(calls) == 1 + assert calls[0].data == { + "entity_id": ["todo.test_list"], + "status": ["needs_action", "completed"], + } + + async def test_no_tools_exposed(hass: HomeAssistant) -> None: """Test that tools are not exposed when no entities are exposed.""" assert await async_setup_component(hass, "homeassistant", {}) From f7a93191222e925f09bae6066aaae37109fa989b Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 30 Apr 2025 14:52:50 +0200 Subject: [PATCH 16/37] Don't attempt to garbage collect objects leaked by previous modules (#143944) --- tests/conftest.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/conftest.py b/tests/conftest.py index a44c6bbb0019a3..efbd6f01cf77e1 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -286,6 +286,7 @@ def garbage_collection() -> None: to run per test case if needed. """ gc.collect() + gc.freeze() @pytest.fixture(autouse=True) From d606e86b47602f9632680eaca52a60ff18c8827a Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Wed, 30 Apr 2025 14:53:03 +0200 Subject: [PATCH 17/37] Fix spelling of "Overtorque fault" in `litterrobot` (#143953) --- homeassistant/components/litterrobot/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/litterrobot/strings.json b/homeassistant/components/litterrobot/strings.json index 55dbc0ea645446..c791629fa32b3a 100644 --- a/homeassistant/components/litterrobot/strings.json +++ b/homeassistant/components/litterrobot/strings.json @@ -93,7 +93,7 @@ "hpf": "Home position fault", "off": "[%key:common::state::off%]", "offline": "Offline", - "otf": "Over torque fault", + "otf": "Overtorque fault", "p": "[%key:common::state::paused%]", "pd": "Pinch detect", "pwrd": "Powering down", From 57a7c26c647de099a89e568fcc794e6c230f4416 Mon Sep 17 00:00:00 2001 From: Pete Sage <76050312+PeteRager@users.noreply.github.com> Date: Wed, 30 Apr 2025 08:55:12 -0400 Subject: [PATCH 18/37] Add generator status sensors for Rehlko (#143948) --- homeassistant/components/rehlko/icons.json | 6 + homeassistant/components/rehlko/sensor.py | 14 ++ homeassistant/components/rehlko/strings.json | 9 ++ .../rehlko/snapshots/test_sensor.ambr | 142 ++++++++++++++++++ 4 files changed, 171 insertions(+) diff --git a/homeassistant/components/rehlko/icons.json b/homeassistant/components/rehlko/icons.json index cb409eba14f4e3..309fc2ffd276c1 100644 --- a/homeassistant/components/rehlko/icons.json +++ b/homeassistant/components/rehlko/icons.json @@ -12,6 +12,12 @@ }, "server_ip_address": { "default": "mdi:server-network" + }, + "generator_status": { + "default": "mdi:home-lightning-bolt" + }, + "power_source": { + "default": "mdi:transmission-tower" } } } diff --git a/homeassistant/components/rehlko/sensor.py b/homeassistant/components/rehlko/sensor.py index c2841e5e435cf9..d19e37d648aa0e 100644 --- a/homeassistant/components/rehlko/sensor.py +++ b/homeassistant/components/rehlko/sensor.py @@ -168,6 +168,20 @@ class RehlkoSensorEntityDescription(SensorEntityDescription): state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, ), + RehlkoSensorEntityDescription( + key="status", + translation_key="generator_status", + use_device_key=True, + ), + RehlkoSensorEntityDescription( + key="engineState", + translation_key="engine_state", + ), + RehlkoSensorEntityDescription( + key="powerSource", + icon="mdi:home-lightning-bolt", + translation_key="power_source", + ), ) diff --git a/homeassistant/components/rehlko/strings.json b/homeassistant/components/rehlko/strings.json index e37f3e8684e0f7..6b842173558d66 100644 --- a/homeassistant/components/rehlko/strings.json +++ b/homeassistant/components/rehlko/strings.json @@ -82,6 +82,15 @@ }, "generator_load_percent": { "name": "Generator load percentage" + }, + "engine_state": { + "name": "Engine state" + }, + "power_source": { + "name": "Power source" + }, + "generator_status": { + "name": "Generator status" } } }, diff --git a/tests/components/rehlko/snapshots/test_sensor.ambr b/tests/components/rehlko/snapshots/test_sensor.ambr index 17bb2524b35bb7..c6cab36ba21ad3 100644 --- a/tests/components/rehlko/snapshots/test_sensor.ambr +++ b/tests/components/rehlko/snapshots/test_sensor.ambr @@ -412,6 +412,53 @@ 'state': '0', }) # --- +# name: test_sensors[sensor.generator_1_engine_state-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.generator_1_engine_state', + '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': 'Engine state', + 'platform': 'rehlko', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'engine_state', + 'unique_id': 'myemail@email.com_12345_engineState', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[sensor.generator_1_engine_state-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Generator 1 Engine state', + }), + 'context': , + 'entity_id': 'sensor.generator_1_engine_state', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'Standby', + }) +# --- # name: test_sensors[sensor.generator_1_generator_load-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -515,6 +562,53 @@ 'state': '0', }) # --- +# name: test_sensors[sensor.generator_1_generator_status-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.generator_1_generator_status', + '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': 'Generator status', + 'platform': 'rehlko', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'generator_status', + 'unique_id': 'myemail@email.com_12345_status', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[sensor.generator_1_generator_status-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Generator 1 Generator status', + }), + 'context': , + 'entity_id': 'sensor.generator_1_generator_status', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'ReadyToRun', + }) +# --- # name: test_sensors[sensor.generator_1_lube_oil_temperature-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -567,6 +661,54 @@ 'state': '6.0', }) # --- +# name: test_sensors[sensor.generator_1_power_source-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.generator_1_power_source', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:home-lightning-bolt', + 'original_name': 'Power source', + 'platform': 'rehlko', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'power_source', + 'unique_id': 'myemail@email.com_12345_powerSource', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[sensor.generator_1_power_source-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Generator 1 Power source', + 'icon': 'mdi:home-lightning-bolt', + }), + 'context': , + 'entity_id': 'sensor.generator_1_power_source', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'Utility', + }) +# --- # name: test_sensors[sensor.generator_1_runtime_since_last_maintenance-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ From f7c1a0c5e6c302c57a72dcc3292dd41580bd9696 Mon Sep 17 00:00:00 2001 From: Brian Choromanski Date: Wed, 30 Apr 2025 08:58:17 -0400 Subject: [PATCH 19/37] Add tests for parse_time_expression (#143912) --- tests/util/test_dt.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/tests/util/test_dt.py b/tests/util/test_dt.py index 96ba8d0a325ebc..3f28896200933f 100644 --- a/tests/util/test_dt.py +++ b/tests/util/test_dt.py @@ -298,6 +298,10 @@ def test_parse_time_expression() -> None: assert list(range(0, 60, 5)) == dt_util.parse_time_expression("/5", 0, 59) + assert dt_util.parse_time_expression("/4", 5, 20) == [8, 12, 16, 20] + assert dt_util.parse_time_expression("/10", 10, 30) == [10, 20, 30] + assert dt_util.parse_time_expression("/3", 4, 29) == [6, 9, 12, 15, 18, 21, 24, 27] + assert dt_util.parse_time_expression([2, 1, 3], 0, 59) == [1, 2, 3] assert list(range(24)) == dt_util.parse_time_expression("*", 0, 23) From 9b1c6b07f5a5e236d1acd04c6870b45080e5bbf6 Mon Sep 17 00:00:00 2001 From: Robert Resch Date: Wed, 30 Apr 2025 15:24:54 +0200 Subject: [PATCH 20/37] Bump deebot-client to 13.0.0 (#143823) --- homeassistant/components/ecovacs/binary_sensor.py | 8 ++++---- homeassistant/components/ecovacs/image.py | 8 ++++++-- homeassistant/components/ecovacs/manifest.json | 2 +- homeassistant/components/ecovacs/select.py | 9 +++++---- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/ecovacs/test_binary_sensor.py | 10 +++------- tests/components/ecovacs/test_select.py | 4 ++-- 8 files changed, 23 insertions(+), 22 deletions(-) diff --git a/homeassistant/components/ecovacs/binary_sensor.py b/homeassistant/components/ecovacs/binary_sensor.py index 552a8152cc5e7b..73b21d4574d41f 100644 --- a/homeassistant/components/ecovacs/binary_sensor.py +++ b/homeassistant/components/ecovacs/binary_sensor.py @@ -5,7 +5,7 @@ from typing import Generic from deebot_client.capabilities import CapabilityEvent -from deebot_client.events.water_info import WaterInfoEvent +from deebot_client.events.water_info import MopAttachedEvent from homeassistant.components.binary_sensor import ( BinarySensorEntity, @@ -32,9 +32,9 @@ class EcovacsBinarySensorEntityDescription( ENTITY_DESCRIPTIONS: tuple[EcovacsBinarySensorEntityDescription, ...] = ( - EcovacsBinarySensorEntityDescription[WaterInfoEvent]( - capability_fn=lambda caps: caps.water, - value_fn=lambda e: e.mop_attached, + EcovacsBinarySensorEntityDescription[MopAttachedEvent]( + capability_fn=lambda caps: caps.water.mop_attached if caps.water else None, + value_fn=lambda e: e.value, key="water_mop_attached", translation_key="water_mop_attached", entity_category=EntityCategory.DIAGNOSTIC, diff --git a/homeassistant/components/ecovacs/image.py b/homeassistant/components/ecovacs/image.py index f8a89b0cfa0f8b..b1c2f0075f12dd 100644 --- a/homeassistant/components/ecovacs/image.py +++ b/homeassistant/components/ecovacs/image.py @@ -1,8 +1,11 @@ """Ecovacs image entities.""" +from typing import cast + from deebot_client.capabilities import CapabilityMap from deebot_client.device import Device from deebot_client.events.map import CachedMapInfoEvent, MapChangedEvent +from deebot_client.map import Map from homeassistant.components.image import ImageEntity from homeassistant.core import HomeAssistant @@ -47,6 +50,7 @@ def __init__( """Initialize entity.""" super().__init__(device, capability, hass=hass) self._attr_extra_state_attributes = {} + self._map = cast(Map, self._device.map) entity_description = EntityDescription( key="map", @@ -55,7 +59,7 @@ def __init__( def image(self) -> bytes | None: """Return bytes of image or None.""" - if svg := self._device.map.get_svg_map(): + if svg := self._map.get_svg_map(): return svg.encode() return None @@ -80,4 +84,4 @@ async def async_update(self) -> None: Only used by the generic entity update service. """ await super().async_update() - self._device.map.refresh() + self._map.refresh() diff --git a/homeassistant/components/ecovacs/manifest.json b/homeassistant/components/ecovacs/manifest.json index ad8b3ea70a5609..2a332e498c7e30 100644 --- a/homeassistant/components/ecovacs/manifest.json +++ b/homeassistant/components/ecovacs/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/ecovacs", "iot_class": "cloud_push", "loggers": ["sleekxmppfs", "sucks", "deebot_client"], - "requirements": ["py-sucks==0.9.10", "deebot-client==12.5.0"] + "requirements": ["py-sucks==0.9.10", "deebot-client==13.0.1"] } diff --git a/homeassistant/components/ecovacs/select.py b/homeassistant/components/ecovacs/select.py index a7b9baf1c4a496..312924013432b5 100644 --- a/homeassistant/components/ecovacs/select.py +++ b/homeassistant/components/ecovacs/select.py @@ -6,7 +6,8 @@ from deebot_client.capabilities import CapabilitySetTypes from deebot_client.device import Device -from deebot_client.events import WaterInfoEvent, WorkModeEvent +from deebot_client.events import WorkModeEvent +from deebot_client.events.water_info import WaterAmountEvent from homeassistant.components.select import SelectEntity, SelectEntityDescription from homeassistant.const import EntityCategory @@ -31,9 +32,9 @@ class EcovacsSelectEntityDescription( ENTITY_DESCRIPTIONS: tuple[EcovacsSelectEntityDescription, ...] = ( - EcovacsSelectEntityDescription[WaterInfoEvent]( - capability_fn=lambda caps: caps.water, - current_option_fn=lambda e: get_name_key(e.amount), + EcovacsSelectEntityDescription[WaterAmountEvent]( + capability_fn=lambda caps: caps.water.amount if caps.water else None, + current_option_fn=lambda e: get_name_key(e.value), options_fn=lambda water: [get_name_key(amount) for amount in water.types], key="water_amount", translation_key="water_amount", diff --git a/requirements_all.txt b/requirements_all.txt index 2272af56c50bd0..88c5df7384cbb2 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -762,7 +762,7 @@ debugpy==1.8.13 # decora==0.6 # homeassistant.components.ecovacs -deebot-client==12.5.0 +deebot-client==13.0.1 # homeassistant.components.ihc # homeassistant.components.namecheapdns diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 723d0f352c345a..0e97ada4d9eec6 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -653,7 +653,7 @@ dbus-fast==2.43.0 debugpy==1.8.13 # homeassistant.components.ecovacs -deebot-client==12.5.0 +deebot-client==13.0.1 # homeassistant.components.ihc # homeassistant.components.namecheapdns diff --git a/tests/components/ecovacs/test_binary_sensor.py b/tests/components/ecovacs/test_binary_sensor.py index b57f67e948e57b..16e2d3fefc5dce 100644 --- a/tests/components/ecovacs/test_binary_sensor.py +++ b/tests/components/ecovacs/test_binary_sensor.py @@ -1,6 +1,6 @@ """Tests for Ecovacs binary sensors.""" -from deebot_client.events import WaterAmount, WaterInfoEvent +from deebot_client.events.water_info import MopAttachedEvent import pytest from syrupy import SnapshotAssertion @@ -43,16 +43,12 @@ async def test_mop_attached( assert device_entry.identifiers == {(DOMAIN, device.device_info["did"])} event_bus = device.events - await notify_and_wait( - hass, event_bus, WaterInfoEvent(WaterAmount.HIGH, mop_attached=True) - ) + await notify_and_wait(hass, event_bus, MopAttachedEvent(True)) assert (state := hass.states.get(state.entity_id)) assert state == snapshot(name=f"{entity_id}-state") - await notify_and_wait( - hass, event_bus, WaterInfoEvent(WaterAmount.HIGH, mop_attached=False) - ) + await notify_and_wait(hass, event_bus, MopAttachedEvent(False)) assert (state := hass.states.get(state.entity_id)) assert state.state == STATE_OFF diff --git a/tests/components/ecovacs/test_select.py b/tests/components/ecovacs/test_select.py index 02a6b5ebfa4d8b..1e03bb18e28f9a 100644 --- a/tests/components/ecovacs/test_select.py +++ b/tests/components/ecovacs/test_select.py @@ -3,7 +3,7 @@ from deebot_client.command import Command from deebot_client.commands.json import SetWaterInfo from deebot_client.event_bus import EventBus -from deebot_client.events import WaterAmount, WaterInfoEvent +from deebot_client.events.water_info import WaterAmount, WaterAmountEvent import pytest from syrupy import SnapshotAssertion @@ -33,7 +33,7 @@ def platforms() -> Platform | list[Platform]: async def notify_events(hass: HomeAssistant, event_bus: EventBus): """Notify events.""" - event_bus.notify(WaterInfoEvent(WaterAmount.ULTRAHIGH)) + event_bus.notify(WaterAmountEvent(WaterAmount.ULTRAHIGH)) await block_till_done(hass, event_bus) From 800f403643db2b42126ec7a000782efedb693878 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 30 Apr 2025 15:25:50 +0200 Subject: [PATCH 21/37] Adjust unique_id in SamsungTV tests (#143959) --- tests/components/samsungtv/__init__.py | 5 ++++- tests/components/samsungtv/test_device_trigger.py | 8 ++++++-- tests/components/samsungtv/test_diagnostics.py | 6 +++--- tests/components/samsungtv/test_init.py | 4 ++-- tests/components/samsungtv/test_media_player.py | 2 +- tests/components/samsungtv/test_remote.py | 4 ++-- tests/components/samsungtv/test_trigger.py | 4 +++- 7 files changed, 21 insertions(+), 12 deletions(-) diff --git a/tests/components/samsungtv/__init__.py b/tests/components/samsungtv/__init__.py index f77cd7a9b3ed00..108a8a3eaa7879 100644 --- a/tests/components/samsungtv/__init__.py +++ b/tests/components/samsungtv/__init__.py @@ -25,7 +25,10 @@ async def async_wait_config_entry_reload(hass: HomeAssistant) -> None: async def setup_samsungtv_entry(hass: HomeAssistant, data: ConfigType) -> ConfigEntry: """Set up mock Samsung TV from config entry data.""" entry = MockConfigEntry( - domain=DOMAIN, data=data, entry_id="123456", unique_id="any" + domain=DOMAIN, + data=data, + entry_id="123456", + unique_id="be9554b9-c9fb-41f4-8920-22da015376a4", ) entry.add_to_hass(hass) diff --git a/tests/components/samsungtv/test_device_trigger.py b/tests/components/samsungtv/test_device_trigger.py index fa6efd0807667e..e67f154cae1c6f 100644 --- a/tests/components/samsungtv/test_device_trigger.py +++ b/tests/components/samsungtv/test_device_trigger.py @@ -28,7 +28,9 @@ async def test_get_triggers( """Test we get the expected triggers.""" await setup_samsungtv_entry(hass, MOCK_ENTRYDATA_ENCRYPTED_WS) - device = device_registry.async_get_device(identifiers={(DOMAIN, "any")}) + device = device_registry.async_get_device( + identifiers={(DOMAIN, "be9554b9-c9fb-41f4-8920-22da015376a4")} + ) turn_on_trigger = { "platform": "device", @@ -54,7 +56,9 @@ async def test_if_fires_on_turn_on_request( await setup_samsungtv_entry(hass, MOCK_ENTRYDATA_ENCRYPTED_WS) entity_id = "media_player.fake" - device = device_registry.async_get_device(identifiers={(DOMAIN, "any")}) + device = device_registry.async_get_device( + identifiers={(DOMAIN, "be9554b9-c9fb-41f4-8920-22da015376a4")} + ) assert await async_setup_component( hass, diff --git a/tests/components/samsungtv/test_diagnostics.py b/tests/components/samsungtv/test_diagnostics.py index e8e0b699a7e1ed..53d52456de5090 100644 --- a/tests/components/samsungtv/test_diagnostics.py +++ b/tests/components/samsungtv/test_diagnostics.py @@ -53,7 +53,7 @@ async def test_entry_diagnostics( "source": "user", "subentries": [], "title": "Mock Title", - "unique_id": "any", + "unique_id": "be9554b9-c9fb-41f4-8920-22da015376a4", "version": 2, }, "device_info": SAMPLE_DEVICE_INFO_WIFI, @@ -94,7 +94,7 @@ async def test_entry_diagnostics_encrypted( "source": "user", "subentries": [], "title": "Mock Title", - "unique_id": "any", + "unique_id": "be9554b9-c9fb-41f4-8920-22da015376a4", "version": 2, }, "device_info": SAMPLE_DEVICE_INFO_UE48JU6400, @@ -134,7 +134,7 @@ async def test_entry_diagnostics_encrypte_offline( "source": "user", "subentries": [], "title": "Mock Title", - "unique_id": "any", + "unique_id": "be9554b9-c9fb-41f4-8920-22da015376a4", "version": 2, }, "device_info": None, diff --git a/tests/components/samsungtv/test_init.py b/tests/components/samsungtv/test_init.py index 5715bd4b0aadee..f0a5c9284b9bbb 100644 --- a/tests/components/samsungtv/test_init.py +++ b/tests/components/samsungtv/test_init.py @@ -235,7 +235,7 @@ async def test_cleanup_mac( domain=DOMAIN, data=MOCK_ENTRY_WS_WITH_MAC, entry_id="123456", - unique_id="any", + unique_id="be9554b9-c9fb-41f4-8920-22da015376a4", version=2, minor_version=1, ) @@ -248,7 +248,7 @@ async def test_cleanup_mac( (dr.CONNECTION_NETWORK_MAC, "none"), (dr.CONNECTION_NETWORK_MAC, "aa:bb:cc:dd:ee:ff"), }, - identifiers={("samsungtv", "any")}, + identifiers={("samsungtv", "be9554b9-c9fb-41f4-8920-22da015376a4")}, model="82GXARRS", name="fake", ) diff --git a/tests/components/samsungtv/test_media_player.py b/tests/components/samsungtv/test_media_player.py index 3d9633bbf9613e..ac9214dd1bd914 100644 --- a/tests/components/samsungtv/test_media_player.py +++ b/tests/components/samsungtv/test_media_player.py @@ -1005,7 +1005,7 @@ async def test_turn_on_wol(hass: HomeAssistant) -> None: entry = MockConfigEntry( domain=DOMAIN, data=MOCK_ENTRY_WS_WITH_MAC, - unique_id="any", + unique_id="be9554b9-c9fb-41f4-8920-22da015376a4", ) entry.add_to_hass(hass) assert await hass.config_entries.async_setup(entry.entry_id) diff --git a/tests/components/samsungtv/test_remote.py b/tests/components/samsungtv/test_remote.py index da7871ca9c50af..65474979968fd7 100644 --- a/tests/components/samsungtv/test_remote.py +++ b/tests/components/samsungtv/test_remote.py @@ -39,7 +39,7 @@ async def test_unique_id( await setup_samsungtv_entry(hass, MOCK_ENTRYDATA_ENCRYPTED_WS) main = entity_registry.async_get(ENTITY_ID) - assert main.unique_id == "any" + assert main.unique_id == "be9554b9-c9fb-41f4-8920-22da015376a4" @pytest.mark.usefixtures("remoteencws", "rest_api") @@ -104,7 +104,7 @@ async def test_turn_on_wol(hass: HomeAssistant) -> None: entry = MockConfigEntry( domain=DOMAIN, data=MOCK_ENTRY_WS_WITH_MAC, - unique_id="any", + unique_id="be9554b9-c9fb-41f4-8920-22da015376a4", ) entry.add_to_hass(hass) assert await hass.config_entries.async_setup(entry.entry_id) diff --git a/tests/components/samsungtv/test_trigger.py b/tests/components/samsungtv/test_trigger.py index e1d26043bb070c..d957e501775a65 100644 --- a/tests/components/samsungtv/test_trigger.py +++ b/tests/components/samsungtv/test_trigger.py @@ -30,7 +30,9 @@ async def test_turn_on_trigger_device_id( entity_id = f"{entity_domain}.fake" - device = device_registry.async_get_device(identifiers={(DOMAIN, "any")}) + device = device_registry.async_get_device( + identifiers={(DOMAIN, "be9554b9-c9fb-41f4-8920-22da015376a4")} + ) assert device, repr(device_registry.devices) assert await async_setup_component( From c6bdee8dd889c65ff4c18d93e6bd2b0177e4bee6 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 30 Apr 2025 15:26:39 +0200 Subject: [PATCH 22/37] Various minor tweaks in samsungtv tests (#143951) --- tests/components/samsungtv/__init__.py | 7 +++++-- tests/components/samsungtv/conftest.py | 6 +++--- tests/components/samsungtv/test_init.py | 2 +- 3 files changed, 9 insertions(+), 6 deletions(-) diff --git a/tests/components/samsungtv/__init__.py b/tests/components/samsungtv/__init__.py index 108a8a3eaa7879..06cc2a6848fcf8 100644 --- a/tests/components/samsungtv/__init__.py +++ b/tests/components/samsungtv/__init__.py @@ -2,12 +2,13 @@ from __future__ import annotations +from collections.abc import Mapping from datetime import timedelta +from typing import Any from homeassistant.components.samsungtv.const import DOMAIN, ENTRY_RELOAD_COOLDOWN from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from homeassistant.helpers.typing import ConfigType from homeassistant.util import dt as dt_util from tests.common import MockConfigEntry, async_fire_time_changed @@ -22,7 +23,9 @@ async def async_wait_config_entry_reload(hass: HomeAssistant) -> None: await hass.async_block_till_done() -async def setup_samsungtv_entry(hass: HomeAssistant, data: ConfigType) -> ConfigEntry: +async def setup_samsungtv_entry( + hass: HomeAssistant, data: Mapping[str, Any] +) -> ConfigEntry: """Set up mock Samsung TV from config entry data.""" entry = MockConfigEntry( domain=DOMAIN, diff --git a/tests/components/samsungtv/conftest.py b/tests/components/samsungtv/conftest.py index f5ae787ab26900..e59c0cc0126ab6 100644 --- a/tests/components/samsungtv/conftest.py +++ b/tests/components/samsungtv/conftest.py @@ -92,13 +92,13 @@ def upnp_factory_fixture() -> Generator[Mock]: @pytest.fixture(name="upnp_device") -def upnp_device_fixture(upnp_factory: Mock) -> Generator[Mock]: +def upnp_device_fixture(upnp_factory: Mock) -> Mock: """Patch async_upnp_client.""" upnp_device = Mock(UpnpDevice) upnp_device.services = {} - with patch.object(upnp_factory, "async_create_device", side_effect=[upnp_device]): - yield upnp_device + upnp_factory.async_create_device.side_effect = [upnp_device] + return upnp_device @pytest.fixture(name="dmr_device") diff --git a/tests/components/samsungtv/test_init.py b/tests/components/samsungtv/test_init.py index f0a5c9284b9bbb..9f1efc0f013d40 100644 --- a/tests/components/samsungtv/test_init.py +++ b/tests/components/samsungtv/test_init.py @@ -72,7 +72,7 @@ async def test_setup(hass: HomeAssistant) -> None: == SUPPORT_SAMSUNGTV | MediaPlayerEntityFeature.TURN_ON ) - # test host and port + # Ensure service is registered await hass.services.async_call( MP_DOMAIN, SERVICE_VOLUME_UP, {ATTR_ENTITY_ID: ENTITY_ID}, True ) From 03ecd7f06ccdd5a92981d64f1831a34ae5377e81 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 30 Apr 2025 15:33:14 +0200 Subject: [PATCH 23/37] Remove icon from rehlko power_source (#143955) --- homeassistant/components/rehlko/sensor.py | 1 - tests/components/rehlko/snapshots/test_sensor.ambr | 3 +-- 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/homeassistant/components/rehlko/sensor.py b/homeassistant/components/rehlko/sensor.py index d19e37d648aa0e..9186f0e0c9fd4a 100644 --- a/homeassistant/components/rehlko/sensor.py +++ b/homeassistant/components/rehlko/sensor.py @@ -179,7 +179,6 @@ class RehlkoSensorEntityDescription(SensorEntityDescription): ), RehlkoSensorEntityDescription( key="powerSource", - icon="mdi:home-lightning-bolt", translation_key="power_source", ), ) diff --git a/tests/components/rehlko/snapshots/test_sensor.ambr b/tests/components/rehlko/snapshots/test_sensor.ambr index c6cab36ba21ad3..3973996ba80227 100644 --- a/tests/components/rehlko/snapshots/test_sensor.ambr +++ b/tests/components/rehlko/snapshots/test_sensor.ambr @@ -685,7 +685,7 @@ 'options': dict({ }), 'original_device_class': None, - 'original_icon': 'mdi:home-lightning-bolt', + 'original_icon': None, 'original_name': 'Power source', 'platform': 'rehlko', 'previous_unique_id': None, @@ -699,7 +699,6 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Generator 1 Power source', - 'icon': 'mdi:home-lightning-bolt', }), 'context': , 'entity_id': 'sensor.generator_1_power_source', From 857db679ae8fdbc60e856c48c40215b971d41e81 Mon Sep 17 00:00:00 2001 From: Sid <27780930+autinerd@users.noreply.github.com> Date: Wed, 30 Apr 2025 15:34:28 +0200 Subject: [PATCH 24/37] Add time platform to eheimdigital (#143168) --- .../components/eheimdigital/__init__.py | 8 +- .../components/eheimdigital/icons.json | 8 + .../components/eheimdigital/strings.json | 8 + homeassistant/components/eheimdigital/time.py | 132 ++++++++++++ tests/components/eheimdigital/conftest.py | 7 + .../eheimdigital/snapshots/test_time.ambr | 189 ++++++++++++++++++ tests/components/eheimdigital/test_time.py | 179 +++++++++++++++++ 7 files changed, 530 insertions(+), 1 deletion(-) create mode 100644 homeassistant/components/eheimdigital/time.py create mode 100644 tests/components/eheimdigital/snapshots/test_time.ambr create mode 100644 tests/components/eheimdigital/test_time.py diff --git a/homeassistant/components/eheimdigital/__init__.py b/homeassistant/components/eheimdigital/__init__.py index 77e722f3e0c739..fee2db089b2d01 100644 --- a/homeassistant/components/eheimdigital/__init__.py +++ b/homeassistant/components/eheimdigital/__init__.py @@ -9,7 +9,13 @@ from .const import DOMAIN from .coordinator import EheimDigitalConfigEntry, EheimDigitalUpdateCoordinator -PLATFORMS = [Platform.CLIMATE, Platform.LIGHT, Platform.NUMBER, Platform.SENSOR] +PLATFORMS = [ + Platform.CLIMATE, + Platform.LIGHT, + Platform.NUMBER, + Platform.SENSOR, + Platform.TIME, +] async def async_setup_entry( diff --git a/homeassistant/components/eheimdigital/icons.json b/homeassistant/components/eheimdigital/icons.json index 428e383dd831de..a09e15e008c792 100644 --- a/homeassistant/components/eheimdigital/icons.json +++ b/homeassistant/components/eheimdigital/icons.json @@ -30,6 +30,14 @@ "no_error": "mdi:check-circle" } } + }, + "time": { + "day_start_time": { + "default": "mdi:weather-sunny" + }, + "night_start_time": { + "default": "mdi:moon-waning-crescent" + } } } } diff --git a/homeassistant/components/eheimdigital/strings.json b/homeassistant/components/eheimdigital/strings.json index d7a14b023f7d65..97a3fbe4e0da1d 100644 --- a/homeassistant/components/eheimdigital/strings.json +++ b/homeassistant/components/eheimdigital/strings.json @@ -79,6 +79,14 @@ "air_in_filter": "Air in filter" } } + }, + "time": { + "day_start_time": { + "name": "Day start time" + }, + "night_start_time": { + "name": "Night start time" + } } } } diff --git a/homeassistant/components/eheimdigital/time.py b/homeassistant/components/eheimdigital/time.py new file mode 100644 index 00000000000000..ae64fad0c92c56 --- /dev/null +++ b/homeassistant/components/eheimdigital/time.py @@ -0,0 +1,132 @@ +"""EHEIM Digital time entities.""" + +from collections.abc import Awaitable, Callable +from dataclasses import dataclass +from datetime import time +from typing import Generic, TypeVar, final, override + +from eheimdigital.classic_vario import EheimDigitalClassicVario +from eheimdigital.device import EheimDigitalDevice +from eheimdigital.heater import EheimDigitalHeater + +from homeassistant.components.time import TimeEntity, TimeEntityDescription +from homeassistant.const import EntityCategory +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from .coordinator import EheimDigitalConfigEntry, EheimDigitalUpdateCoordinator +from .entity import EheimDigitalEntity + +PARALLEL_UPDATES = 0 + +_DeviceT_co = TypeVar("_DeviceT_co", bound=EheimDigitalDevice, covariant=True) + + +@dataclass(frozen=True, kw_only=True) +class EheimDigitalTimeDescription(TimeEntityDescription, Generic[_DeviceT_co]): + """Class describing EHEIM Digital time entities.""" + + value_fn: Callable[[_DeviceT_co], time | None] + set_value_fn: Callable[[_DeviceT_co, time], Awaitable[None]] + + +CLASSICVARIO_DESCRIPTIONS: tuple[ + EheimDigitalTimeDescription[EheimDigitalClassicVario], ... +] = ( + EheimDigitalTimeDescription[EheimDigitalClassicVario]( + key="day_start_time", + translation_key="day_start_time", + entity_category=EntityCategory.CONFIG, + value_fn=lambda device: device.day_start_time, + set_value_fn=lambda device, value: device.set_day_start_time(value), + ), + EheimDigitalTimeDescription[EheimDigitalClassicVario]( + key="night_start_time", + translation_key="night_start_time", + entity_category=EntityCategory.CONFIG, + value_fn=lambda device: device.night_start_time, + set_value_fn=lambda device, value: device.set_night_start_time(value), + ), +) + +HEATER_DESCRIPTIONS: tuple[EheimDigitalTimeDescription[EheimDigitalHeater], ...] = ( + EheimDigitalTimeDescription[EheimDigitalHeater]( + key="day_start_time", + translation_key="day_start_time", + entity_category=EntityCategory.CONFIG, + value_fn=lambda device: device.day_start_time, + set_value_fn=lambda device, value: device.set_day_start_time(value), + ), + EheimDigitalTimeDescription[EheimDigitalHeater]( + key="night_start_time", + translation_key="night_start_time", + entity_category=EntityCategory.CONFIG, + value_fn=lambda device: device.night_start_time, + set_value_fn=lambda device, value: device.set_night_start_time(value), + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: EheimDigitalConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up the callbacks for the coordinator so times can be added as devices are found.""" + coordinator = entry.runtime_data + + def async_setup_device_entities( + device_address: dict[str, EheimDigitalDevice], + ) -> None: + """Set up the time entities for one or multiple devices.""" + entities: list[EheimDigitalTime[EheimDigitalDevice]] = [] + for device in device_address.values(): + if isinstance(device, EheimDigitalClassicVario): + entities.extend( + EheimDigitalTime[EheimDigitalClassicVario]( + coordinator, device, description + ) + for description in CLASSICVARIO_DESCRIPTIONS + ) + if isinstance(device, EheimDigitalHeater): + entities.extend( + EheimDigitalTime[EheimDigitalHeater]( + coordinator, device, description + ) + for description in HEATER_DESCRIPTIONS + ) + + async_add_entities(entities) + + coordinator.add_platform_callback(async_setup_device_entities) + async_setup_device_entities(coordinator.hub.devices) + + +@final +class EheimDigitalTime( + EheimDigitalEntity[_DeviceT_co], TimeEntity, Generic[_DeviceT_co] +): + """Represent an EHEIM Digital time entity.""" + + entity_description: EheimDigitalTimeDescription[_DeviceT_co] + + def __init__( + self, + coordinator: EheimDigitalUpdateCoordinator, + device: _DeviceT_co, + description: EheimDigitalTimeDescription[_DeviceT_co], + ) -> None: + """Initialize an EHEIM Digital time entity.""" + super().__init__(coordinator, device) + self.entity_description = description + self._attr_unique_id = f"{device.mac_address}_{description.key}" + + @override + async def async_set_value(self, value: time) -> None: + """Change the time.""" + return await self.entity_description.set_value_fn(self._device, value) + + @override + def _async_update_attrs(self) -> None: + """Update the entity attributes.""" + self._attr_native_value = self.entity_description.value_fn(self._device) diff --git a/tests/components/eheimdigital/conftest.py b/tests/components/eheimdigital/conftest.py index 01ef9e44b5dcef..654028c7c11e03 100644 --- a/tests/components/eheimdigital/conftest.py +++ b/tests/components/eheimdigital/conftest.py @@ -1,6 +1,7 @@ """Configurations for the EHEIM Digital tests.""" from collections.abc import Generator +from datetime import time, timedelta, timezone from unittest.mock import AsyncMock, MagicMock, patch from eheimdigital.classic_led_ctrl import EheimDigitalClassicLEDControl @@ -66,6 +67,8 @@ def heater_mock(): heater_mock.is_heating = True heater_mock.is_active = True heater_mock.operation_mode = HeaterMode.MANUAL + heater_mock.day_start_time = time(8, 0, tzinfo=timezone(timedelta(hours=1))) + heater_mock.night_start_time = time(20, 0, tzinfo=timezone(timedelta(hours=1))) return heater_mock @@ -81,6 +84,10 @@ def classic_vario_mock(): classic_vario_mock.current_speed = 75 classic_vario_mock.manual_speed = 75 classic_vario_mock.day_speed = 80 + classic_vario_mock.day_start_time = time(8, 0, tzinfo=timezone(timedelta(hours=1))) + classic_vario_mock.night_start_time = time( + 20, 0, tzinfo=timezone(timedelta(hours=1)) + ) classic_vario_mock.night_speed = 20 classic_vario_mock.is_active = True classic_vario_mock.filter_mode = FilterMode.MANUAL diff --git a/tests/components/eheimdigital/snapshots/test_time.ambr b/tests/components/eheimdigital/snapshots/test_time.ambr new file mode 100644 index 00000000000000..bdd4bdaddb7862 --- /dev/null +++ b/tests/components/eheimdigital/snapshots/test_time.ambr @@ -0,0 +1,189 @@ +# serializer version: 1 +# name: test_setup[time.mock_classicvario_day_start_time-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.mock_classicvario_day_start_time', + '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': 'Day start time', + 'platform': 'eheimdigital', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'day_start_time', + 'unique_id': '00:00:00:00:00:03_day_start_time', + 'unit_of_measurement': None, + }) +# --- +# name: test_setup[time.mock_classicvario_day_start_time-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Mock classicVARIO Day start time', + }), + 'context': , + 'entity_id': 'time.mock_classicvario_day_start_time', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_setup[time.mock_classicvario_night_start_time-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.mock_classicvario_night_start_time', + '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': 'Night start time', + 'platform': 'eheimdigital', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'night_start_time', + 'unique_id': '00:00:00:00:00:03_night_start_time', + 'unit_of_measurement': None, + }) +# --- +# name: test_setup[time.mock_classicvario_night_start_time-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Mock classicVARIO Night start time', + }), + 'context': , + 'entity_id': 'time.mock_classicvario_night_start_time', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_setup[time.mock_heater_day_start_time-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.mock_heater_day_start_time', + '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': 'Day start time', + 'platform': 'eheimdigital', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'day_start_time', + 'unique_id': '00:00:00:00:00:02_day_start_time', + 'unit_of_measurement': None, + }) +# --- +# name: test_setup[time.mock_heater_day_start_time-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Mock Heater Day start time', + }), + 'context': , + 'entity_id': 'time.mock_heater_day_start_time', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_setup[time.mock_heater_night_start_time-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.mock_heater_night_start_time', + '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': 'Night start time', + 'platform': 'eheimdigital', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'night_start_time', + 'unique_id': '00:00:00:00:00:02_night_start_time', + 'unit_of_measurement': None, + }) +# --- +# name: test_setup[time.mock_heater_night_start_time-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Mock Heater Night start time', + }), + 'context': , + 'entity_id': 'time.mock_heater_night_start_time', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- diff --git a/tests/components/eheimdigital/test_time.py b/tests/components/eheimdigital/test_time.py new file mode 100644 index 00000000000000..acb96ae4023eaa --- /dev/null +++ b/tests/components/eheimdigital/test_time.py @@ -0,0 +1,179 @@ +"""Tests for the time module.""" + +from datetime import time, timedelta, timezone +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest +from syrupy.assertion 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 .conftest import init_integration + +from tests.common import MockConfigEntry, snapshot_platform + + +@pytest.mark.usefixtures("classic_vario_mock", "heater_mock") +async def test_setup( + hass: HomeAssistant, + eheimdigital_hub_mock: MagicMock, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Test number platform setup.""" + mock_config_entry.add_to_hass(hass) + + with ( + patch("homeassistant.components.eheimdigital.PLATFORMS", [Platform.TIME]), + patch( + "homeassistant.components.eheimdigital.coordinator.asyncio.Event", + new=AsyncMock, + ), + ): + await hass.config_entries.async_setup(mock_config_entry.entry_id) + + for device in eheimdigital_hub_mock.return_value.devices: + await eheimdigital_hub_mock.call_args.kwargs["device_found_callback"]( + device, eheimdigital_hub_mock.return_value.devices[device].device_type + ) + await hass.async_block_till_done() + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +@pytest.mark.usefixtures("classic_vario_mock", "heater_mock") +@pytest.mark.parametrize( + ("device_name", "entity_list"), + [ + ( + "heater_mock", + [ + ( + "time.mock_heater_day_start_time", + time(9, 0, tzinfo=timezone(timedelta(hours=1))), + "set_day_start_time", + (time(9, 0, tzinfo=timezone(timedelta(hours=1))),), + ), + ( + "time.mock_heater_night_start_time", + time(19, 0, tzinfo=timezone(timedelta(hours=1))), + "set_night_start_time", + (time(19, 0, tzinfo=timezone(timedelta(hours=1))),), + ), + ], + ), + ( + "classic_vario_mock", + [ + ( + "time.mock_classicvario_day_start_time", + time(9, 0, tzinfo=timezone(timedelta(hours=1))), + "set_day_start_time", + (time(9, 0, tzinfo=timezone(timedelta(hours=1))),), + ), + ( + "time.mock_classicvario_night_start_time", + time(19, 0, tzinfo=timezone(timedelta(hours=1))), + "set_night_start_time", + (time(19, 0, tzinfo=timezone(timedelta(hours=1))),), + ), + ], + ), + ], +) +async def test_set_value( + hass: HomeAssistant, + eheimdigital_hub_mock: MagicMock, + mock_config_entry: MockConfigEntry, + device_name: str, + entity_list: list[tuple[str, time, str, tuple[time]]], + request: pytest.FixtureRequest, +) -> None: + """Test setting a value.""" + device: MagicMock = request.getfixturevalue(device_name) + await init_integration(hass, mock_config_entry) + + await eheimdigital_hub_mock.call_args.kwargs["device_found_callback"]( + device.mac_address, device.device_type + ) + + await hass.async_block_till_done() + + for item in entity_list: + await hass.services.async_call( + TIME_DOMAIN, + SERVICE_SET_VALUE, + {ATTR_ENTITY_ID: item[0], ATTR_TIME: item[1]}, + blocking=True, + ) + calls = [call for call in device.mock_calls if call[0] == item[2]] + assert len(calls) == 1 and calls[0][1] == item[3] + + +@pytest.mark.usefixtures("classic_vario_mock", "heater_mock") +@pytest.mark.parametrize( + ("device_name", "entity_list"), + [ + ( + "heater_mock", + [ + ( + "time.mock_heater_day_start_time", + "day_start_time", + time(9, 0, tzinfo=timezone(timedelta(hours=3))), + ), + ( + "time.mock_heater_night_start_time", + "night_start_time", + time(19, 0, tzinfo=timezone(timedelta(hours=3))), + ), + ], + ), + ( + "classic_vario_mock", + [ + ( + "time.mock_classicvario_day_start_time", + "day_start_time", + time(9, 0, tzinfo=timezone(timedelta(hours=1))), + ), + ( + "time.mock_classicvario_night_start_time", + "night_start_time", + time(22, 0, tzinfo=timezone(timedelta(hours=1))), + ), + ], + ), + ], +) +async def test_state_update( + hass: HomeAssistant, + eheimdigital_hub_mock: MagicMock, + mock_config_entry: MockConfigEntry, + device_name: str, + entity_list: list[tuple[str, str, time]], + request: pytest.FixtureRequest, +) -> None: + """Test state updates.""" + device: MagicMock = request.getfixturevalue(device_name) + await init_integration(hass, mock_config_entry) + + await eheimdigital_hub_mock.call_args.kwargs["device_found_callback"]( + device.mac_address, device.device_type + ) + + await hass.async_block_till_done() + + for item in entity_list: + setattr(device, item[1], item[2]) + await eheimdigital_hub_mock.call_args.kwargs["receive_callback"]() + assert (state := hass.states.get(item[0])) + assert state.state == item[2].isoformat() From 8b9c4dadd0a91744bdb13682fbce0c44a55d0abc Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 30 Apr 2025 15:38:00 +0200 Subject: [PATCH 25/37] Use freezer.tick in SamsungTV tests (#143954) --- tests/components/samsungtv/conftest.py | 8 - .../components/samsungtv/test_media_player.py | 160 +++++++----------- 2 files changed, 59 insertions(+), 109 deletions(-) diff --git a/tests/components/samsungtv/conftest.py b/tests/components/samsungtv/conftest.py index e59c0cc0126ab6..4b3ad59defd43d 100644 --- a/tests/components/samsungtv/conftest.py +++ b/tests/components/samsungtv/conftest.py @@ -3,7 +3,6 @@ from __future__ import annotations from collections.abc import Awaitable, Callable, Generator -from datetime import datetime from socket import AddressFamily # pylint: disable=no-name-in-module from typing import Any from unittest.mock import AsyncMock, Mock, patch @@ -21,7 +20,6 @@ from samsungtvws.remote import ChannelEmitCommand from homeassistant.components.samsungtv.const import WEBSOCKET_SSL_PORT -from homeassistant.util import dt as dt_util from .const import SAMPLE_DEVICE_INFO_UE48JU6400, SAMPLE_DEVICE_INFO_WIFI @@ -285,12 +283,6 @@ def _mock_ws_event_callback(event: str, response: Any): yield remoteencws -@pytest.fixture -def mock_now() -> datetime: - """Fixture for dtutil.now.""" - return dt_util.utcnow() - - @pytest.fixture(name="mac_address", autouse=True) def mac_address_fixture() -> Generator[Mock]: """Patch getmac.get_mac_address.""" diff --git a/tests/components/samsungtv/test_media_player.py b/tests/components/samsungtv/test_media_player.py index ac9214dd1bd914..1ddc2928394db8 100644 --- a/tests/components/samsungtv/test_media_player.py +++ b/tests/components/samsungtv/test_media_player.py @@ -1,7 +1,7 @@ """Tests for samsungtv component.""" from copy import deepcopy -from datetime import datetime, timedelta +from datetime import timedelta import logging from unittest.mock import DEFAULT as DEFAULT_MOCK, AsyncMock, Mock, call, patch @@ -78,7 +78,6 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import ServiceNotSupported from homeassistant.setup import async_setup_component -from homeassistant.util import dt as dt_util from . import async_wait_config_entry_reload, setup_samsungtv_entry from .const import ( @@ -153,7 +152,7 @@ async def test_setup_websocket(hass: HomeAssistant) -> None: @pytest.mark.usefixtures("rest_api") async def test_setup_websocket_2( - hass: HomeAssistant, freezer: FrozenDateTimeFactory, mock_now: datetime + hass: HomeAssistant, freezer: FrozenDateTimeFactory ) -> None: """Test setup of platform from config entry.""" entity_id = f"{MP_DOMAIN}.fake" @@ -182,9 +181,8 @@ async def test_setup_websocket_2( assert config_entries[0].data[CONF_MAC] == "aa:bb:aa:aa:aa:aa" - next_update = mock_now + timedelta(minutes=5) - freezer.move_to(next_update) - async_fire_time_changed(hass, next_update) + freezer.tick(timedelta(minutes=5)) + async_fire_time_changed(hass) await hass.async_block_till_done(wait_background_tasks=True) state = hass.states.get(entity_id) @@ -194,7 +192,7 @@ async def test_setup_websocket_2( @pytest.mark.usefixtures("rest_api") async def test_setup_encrypted_websocket( - hass: HomeAssistant, freezer: FrozenDateTimeFactory, mock_now: datetime + hass: HomeAssistant, freezer: FrozenDateTimeFactory ) -> None: """Test setup of platform from config entry.""" with patch( @@ -207,9 +205,8 @@ async def test_setup_encrypted_websocket( await setup_samsungtv_entry(hass, MOCK_ENTRYDATA_ENCRYPTED_WS) - next_update = mock_now + timedelta(minutes=5) - freezer.move_to(next_update) - async_fire_time_changed(hass, next_update) + freezer.tick(timedelta(minutes=5)) + async_fire_time_changed(hass) await hass.async_block_till_done(wait_background_tasks=True) state = hass.states.get(ENTITY_ID) @@ -218,15 +215,12 @@ async def test_setup_encrypted_websocket( @pytest.mark.usefixtures("remote") -async def test_update_on( - hass: HomeAssistant, freezer: FrozenDateTimeFactory, mock_now: datetime -) -> None: +async def test_update_on(hass: HomeAssistant, freezer: FrozenDateTimeFactory) -> None: """Testing update tv on.""" await setup_samsungtv_entry(hass, MOCK_CONFIG) - next_update = mock_now + timedelta(minutes=5) - freezer.move_to(next_update) - async_fire_time_changed(hass, next_update) + freezer.tick(timedelta(minutes=5)) + async_fire_time_changed(hass) await hass.async_block_till_done(wait_background_tasks=True) state = hass.states.get(ENTITY_ID) @@ -234,9 +228,7 @@ async def test_update_on( @pytest.mark.usefixtures("remote") -async def test_update_off( - hass: HomeAssistant, freezer: FrozenDateTimeFactory, mock_now: datetime -) -> None: +async def test_update_off(hass: HomeAssistant, freezer: FrozenDateTimeFactory) -> None: """Testing update tv off.""" await setup_samsungtv_entry(hass, MOCK_CONFIG) @@ -244,9 +236,8 @@ async def test_update_off( "homeassistant.components.samsungtv.bridge.Remote", side_effect=[OSError("Boom"), DEFAULT_MOCK], ): - next_update = mock_now + timedelta(minutes=5) - freezer.move_to(next_update) - async_fire_time_changed(hass, next_update) + freezer.tick(timedelta(minutes=5)) + async_fire_time_changed(hass) await hass.async_block_till_done(wait_background_tasks=True) state = hass.states.get(ENTITY_ID) @@ -254,11 +245,7 @@ async def test_update_off( async def test_update_off_ws_no_power_state( - hass: HomeAssistant, - freezer: FrozenDateTimeFactory, - remotews: Mock, - rest_api: Mock, - mock_now: datetime, + hass: HomeAssistant, freezer: FrozenDateTimeFactory, remotews: Mock, rest_api: Mock ) -> None: """Testing update tv off.""" await setup_samsungtv_entry(hass, MOCK_CONFIGWS) @@ -272,9 +259,8 @@ async def test_update_off_ws_no_power_state( remotews.start_listening = Mock(side_effect=WebSocketException("Boom")) remotews.is_alive.return_value = False - next_update = mock_now + timedelta(minutes=5) - freezer.move_to(next_update) - async_fire_time_changed(hass, next_update) + freezer.tick(timedelta(minutes=5)) + async_fire_time_changed(hass) await hass.async_block_till_done(wait_background_tasks=True) state = hass.states.get(ENTITY_ID) @@ -284,11 +270,7 @@ async def test_update_off_ws_no_power_state( @pytest.mark.usefixtures("remotews") async def test_update_off_ws_with_power_state( - hass: HomeAssistant, - freezer: FrozenDateTimeFactory, - remotews: Mock, - rest_api: Mock, - mock_now: datetime, + hass: HomeAssistant, freezer: FrozenDateTimeFactory, remotews: Mock, rest_api: Mock ) -> None: """Testing update tv off.""" with ( @@ -311,9 +293,9 @@ async def test_update_off_ws_with_power_state( device_info = deepcopy(SAMPLE_DEVICE_INFO_WIFI) device_info["device"]["PowerState"] = "on" rest_api.rest_device_info.return_value = device_info - next_update = mock_now + timedelta(minutes=1) - freezer.move_to(next_update) - async_fire_time_changed(hass, next_update) + + freezer.tick(timedelta(minutes=1)) + async_fire_time_changed(hass) await hass.async_block_till_done(wait_background_tasks=True) remotews.start_listening.assert_called_once() @@ -327,9 +309,9 @@ async def test_update_off_ws_with_power_state( # Second update uses device_info(ON) rest_api.rest_device_info.reset_mock() - next_update = mock_now + timedelta(minutes=2) - freezer.move_to(next_update) - async_fire_time_changed(hass, next_update) + + freezer.tick(timedelta(minutes=1)) + async_fire_time_changed(hass) await hass.async_block_till_done(wait_background_tasks=True) rest_api.rest_device_info.assert_called_once() @@ -340,9 +322,9 @@ async def test_update_off_ws_with_power_state( # Third update uses device_info (OFF) rest_api.rest_device_info.reset_mock() device_info["device"]["PowerState"] = "off" - next_update = mock_now + timedelta(minutes=3) - freezer.move_to(next_update) - async_fire_time_changed(hass, next_update) + + freezer.tick(timedelta(minutes=1)) + async_fire_time_changed(hass) await hass.async_block_till_done(wait_background_tasks=True) rest_api.rest_device_info.assert_called_once() @@ -358,7 +340,6 @@ async def test_update_off_encryptedws( freezer: FrozenDateTimeFactory, remoteencws: Mock, rest_api: Mock, - mock_now: datetime, ) -> None: """Testing update tv off.""" await setup_samsungtv_entry(hass, MOCK_ENTRYDATA_ENCRYPTED_WS) @@ -371,9 +352,8 @@ async def test_update_off_encryptedws( remoteencws.start_listening = Mock(side_effect=WebSocketException("Boom")) remoteencws.is_alive.return_value = False - next_update = mock_now + timedelta(minutes=5) - freezer.move_to(next_update) - async_fire_time_changed(hass, next_update) + freezer.tick(timedelta(minutes=5)) + async_fire_time_changed(hass) await hass.async_block_till_done(wait_background_tasks=True) state = hass.states.get(ENTITY_ID) @@ -383,7 +363,7 @@ async def test_update_off_encryptedws( @pytest.mark.usefixtures("remote") async def test_update_access_denied( - hass: HomeAssistant, freezer: FrozenDateTimeFactory, mock_now: datetime + hass: HomeAssistant, freezer: FrozenDateTimeFactory ) -> None: """Testing update tv access denied exception.""" await setup_samsungtv_entry(hass, MOCK_CONFIG) @@ -392,14 +372,12 @@ async def test_update_access_denied( "homeassistant.components.samsungtv.bridge.Remote", side_effect=exceptions.AccessDenied("Boom"), ): - next_update = mock_now + timedelta(minutes=5) - freezer.move_to(next_update) - async_fire_time_changed(hass, next_update) + freezer.tick(timedelta(minutes=5)) + async_fire_time_changed(hass) await hass.async_block_till_done(wait_background_tasks=True) - next_update = mock_now + timedelta(minutes=10) - freezer.move_to(next_update) - async_fire_time_changed(hass, next_update) + freezer.tick(timedelta(minutes=5)) + async_fire_time_changed(hass) await hass.async_block_till_done(wait_background_tasks=True) assert [ @@ -415,7 +393,6 @@ async def test_update_access_denied( async def test_update_ws_connection_failure( hass: HomeAssistant, freezer: FrozenDateTimeFactory, - mock_now: datetime, remotews: Mock, caplog: pytest.LogCaptureFixture, ) -> None: @@ -430,9 +407,8 @@ async def test_update_ws_connection_failure( ), patch.object(remotews, "is_alive", return_value=False), ): - next_update = mock_now + timedelta(minutes=5) - freezer.move_to(next_update) - async_fire_time_changed(hass, next_update) + freezer.tick(timedelta(minutes=5)) + async_fire_time_changed(hass) await hass.async_block_till_done(wait_background_tasks=True) assert ( @@ -447,10 +423,7 @@ async def test_update_ws_connection_failure( @pytest.mark.usefixtures("rest_api") async def test_update_ws_connection_closed( - hass: HomeAssistant, - freezer: FrozenDateTimeFactory, - mock_now: datetime, - remotews: Mock, + hass: HomeAssistant, freezer: FrozenDateTimeFactory, remotews: Mock ) -> None: """Testing update tv connection failure exception.""" await setup_samsungtv_entry(hass, MOCK_CONFIGWS) @@ -461,9 +434,8 @@ async def test_update_ws_connection_closed( ), patch.object(remotews, "is_alive", return_value=False), ): - next_update = mock_now + timedelta(minutes=5) - freezer.move_to(next_update) - async_fire_time_changed(hass, next_update) + freezer.tick(timedelta(minutes=5)) + async_fire_time_changed(hass) await hass.async_block_till_done(wait_background_tasks=True) state = hass.states.get(ENTITY_ID) @@ -472,10 +444,7 @@ async def test_update_ws_connection_closed( @pytest.mark.usefixtures("rest_api") async def test_update_ws_unauthorized_error( - hass: HomeAssistant, - freezer: FrozenDateTimeFactory, - mock_now: datetime, - remotews: Mock, + hass: HomeAssistant, freezer: FrozenDateTimeFactory, remotews: Mock ) -> None: """Testing update tv unauthorized failure exception.""" await setup_samsungtv_entry(hass, MOCK_CONFIGWS) @@ -484,9 +453,8 @@ async def test_update_ws_unauthorized_error( patch.object(remotews, "start_listening", side_effect=UnauthorizedError), patch.object(remotews, "is_alive", return_value=False), ): - next_update = mock_now + timedelta(minutes=5) - freezer.move_to(next_update) - async_fire_time_changed(hass, next_update) + freezer.tick(timedelta(minutes=5)) + async_fire_time_changed(hass) await hass.async_block_till_done(wait_background_tasks=True) assert [ @@ -500,7 +468,7 @@ async def test_update_ws_unauthorized_error( @pytest.mark.usefixtures("remote") async def test_update_unhandled_response( - hass: HomeAssistant, freezer: FrozenDateTimeFactory, mock_now: datetime + hass: HomeAssistant, freezer: FrozenDateTimeFactory ) -> None: """Testing update tv unhandled response exception.""" await setup_samsungtv_entry(hass, MOCK_CONFIG) @@ -509,9 +477,8 @@ async def test_update_unhandled_response( "homeassistant.components.samsungtv.bridge.Remote", side_effect=[exceptions.UnhandledResponse("Boom"), DEFAULT_MOCK], ): - next_update = mock_now + timedelta(minutes=5) - freezer.move_to(next_update) - async_fire_time_changed(hass, next_update) + freezer.tick(timedelta(minutes=5)) + async_fire_time_changed(hass) await hass.async_block_till_done(wait_background_tasks=True) state = hass.states.get(ENTITY_ID) @@ -520,7 +487,7 @@ async def test_update_unhandled_response( @pytest.mark.usefixtures("remote") async def test_connection_closed_during_update_can_recover( - hass: HomeAssistant, freezer: FrozenDateTimeFactory, mock_now: datetime + hass: HomeAssistant, freezer: FrozenDateTimeFactory ) -> None: """Testing update tv connection closed exception can recover.""" await setup_samsungtv_entry(hass, MOCK_CONFIG) @@ -529,17 +496,15 @@ async def test_connection_closed_during_update_can_recover( "homeassistant.components.samsungtv.bridge.Remote", side_effect=[exceptions.ConnectionClosed(), DEFAULT_MOCK], ): - next_update = mock_now + timedelta(minutes=5) - freezer.move_to(next_update) - async_fire_time_changed(hass, next_update) + freezer.tick(timedelta(minutes=5)) + async_fire_time_changed(hass) await hass.async_block_till_done(wait_background_tasks=True) state = hass.states.get(ENTITY_ID) assert state.state == STATE_UNAVAILABLE - next_update = mock_now + timedelta(minutes=10) - freezer.move_to(next_update) - async_fire_time_changed(hass, next_update) + freezer.tick(timedelta(minutes=5)) + async_fire_time_changed(hass) await hass.async_block_till_done(wait_background_tasks=True) state = hass.states.get(ENTITY_ID) @@ -689,13 +654,12 @@ async def test_state(hass: HomeAssistant, freezer: FrozenDateTimeFactory) -> Non # Should be STATE_UNAVAILABLE after the timer expires assert state.state == STATE_OFF - next_update = dt_util.utcnow() + timedelta(seconds=20) with patch( "homeassistant.components.samsungtv.bridge.Remote", side_effect=OSError, ): - freezer.move_to(next_update) - async_fire_time_changed(hass, next_update) + freezer.tick(timedelta(seconds=20)) + async_fire_time_changed(hass) await hass.async_block_till_done(wait_background_tasks=True) state = hass.states.get(ENTITY_ID) @@ -1390,7 +1354,6 @@ async def test_upnp_re_subscribe_events( freezer: FrozenDateTimeFactory, remotews: Mock, dmr_device: Mock, - mock_now: datetime, ) -> None: """Test for Upnp event feedback.""" await setup_samsungtv_entry(hass, MOCK_ENTRY_WS) @@ -1406,9 +1369,8 @@ async def test_upnp_re_subscribe_events( ), patch.object(remotews, "is_alive", return_value=False), ): - next_update = mock_now + timedelta(minutes=5) - freezer.move_to(next_update) - async_fire_time_changed(hass, next_update) + freezer.tick(timedelta(minutes=5)) + async_fire_time_changed(hass) await hass.async_block_till_done(wait_background_tasks=True) state = hass.states.get(ENTITY_ID) @@ -1416,9 +1378,8 @@ async def test_upnp_re_subscribe_events( assert dmr_device.async_subscribe_services.call_count == 1 assert dmr_device.async_unsubscribe_services.call_count == 1 - next_update = mock_now + timedelta(minutes=10) - freezer.move_to(next_update) - async_fire_time_changed(hass, next_update) + freezer.tick(timedelta(minutes=5)) + async_fire_time_changed(hass) await hass.async_block_till_done(wait_background_tasks=True) state = hass.states.get(ENTITY_ID) @@ -1437,7 +1398,6 @@ async def test_upnp_failed_re_subscribe_events( freezer: FrozenDateTimeFactory, remotews: Mock, dmr_device: Mock, - mock_now: datetime, caplog: pytest.LogCaptureFixture, error: Exception, ) -> None: @@ -1455,9 +1415,8 @@ async def test_upnp_failed_re_subscribe_events( ), patch.object(remotews, "is_alive", return_value=False), ): - next_update = mock_now + timedelta(minutes=5) - freezer.move_to(next_update) - async_fire_time_changed(hass, next_update) + freezer.tick(timedelta(minutes=5)) + async_fire_time_changed(hass) await hass.async_block_till_done(wait_background_tasks=True) state = hass.states.get(ENTITY_ID) @@ -1465,10 +1424,9 @@ async def test_upnp_failed_re_subscribe_events( assert dmr_device.async_subscribe_services.call_count == 1 assert dmr_device.async_unsubscribe_services.call_count == 1 - next_update = mock_now + timedelta(minutes=10) with patch.object(dmr_device, "async_subscribe_services", side_effect=error): - freezer.move_to(next_update) - async_fire_time_changed(hass, next_update) + freezer.tick(timedelta(minutes=5)) + async_fire_time_changed(hass) await hass.async_block_till_done(wait_background_tasks=True) state = hass.states.get(ENTITY_ID) From af66d0b64701bc85d6d0264f7169c03281143a98 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 30 Apr 2025 15:38:40 +0200 Subject: [PATCH 26/37] Delay register callback in SamsungTV (#143950) --- homeassistant/components/samsungtv/media_player.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/samsungtv/media_player.py b/homeassistant/components/samsungtv/media_player.py index 5a48159b717f7b..4fb2e6bd1a290f 100644 --- a/homeassistant/components/samsungtv/media_player.py +++ b/homeassistant/components/samsungtv/media_player.py @@ -102,8 +102,6 @@ def __init__(self, coordinator: SamsungTVDataUpdateCoordinator) -> None: if self._ssdp_rendering_control_location: self._attr_supported_features |= MediaPlayerEntityFeature.VOLUME_SET - self._bridge.register_app_list_callback(self._app_list_callback) - self._dmr_device: DmrDevice | None = None self._upnp_server: AiohttpNotifyServer | None = None @@ -130,8 +128,11 @@ def _app_list_callback(self, app_list: dict[str, str]) -> None: async def async_added_to_hass(self) -> None: """Run when entity about to be added to hass.""" await super().async_added_to_hass() + + self._bridge.register_app_list_callback(self._app_list_callback) await self._async_extra_update() self.coordinator.async_extra_update = self._async_extra_update + if self.coordinator.is_on: self._attr_state = MediaPlayerState.ON self._update_from_upnp() From 923300f4e7733a18100965407de1d7e96bf46381 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Wed, 30 Apr 2025 15:39:23 +0200 Subject: [PATCH 27/37] Add Sabbath mode to SmartThings (#141072) --- .../components/smartthings/strings.json | 3 ++ .../components/smartthings/switch.py | 5 ++ .../smartthings/snapshots/test_switch.ambr | 47 +++++++++++++++++++ 3 files changed, 55 insertions(+) diff --git a/homeassistant/components/smartthings/strings.json b/homeassistant/components/smartthings/strings.json index 384264b05953dd..f925376eea7bd7 100644 --- a/homeassistant/components/smartthings/strings.json +++ b/homeassistant/components/smartthings/strings.json @@ -478,6 +478,9 @@ }, "ice_maker": { "name": "Ice maker" + }, + "sabbath_mode": { + "name": "Sabbath mode" } } }, diff --git a/homeassistant/components/smartthings/switch.py b/homeassistant/components/smartthings/switch.py index af019709fb9c55..56e67ad2a136d8 100644 --- a/homeassistant/components/smartthings/switch.py +++ b/homeassistant/components/smartthings/switch.py @@ -87,6 +87,11 @@ class SmartThingsCommandSwitchEntityDescription(SmartThingsSwitchEntityDescripti "icemaker": "ice_maker", }, ), + Capability.SAMSUNG_CE_SABBATH_MODE: SmartThingsSwitchEntityDescription( + key=Capability.SAMSUNG_CE_SABBATH_MODE, + translation_key="sabbath_mode", + status_attribute=Attribute.STATUS, + ), } diff --git a/tests/components/smartthings/snapshots/test_switch.ambr b/tests/components/smartthings/snapshots/test_switch.ambr index be605bc7036257..4245d2bb0954b2 100644 --- a/tests/components/smartthings/snapshots/test_switch.ambr +++ b/tests/components/smartthings/snapshots/test_switch.ambr @@ -93,6 +93,53 @@ 'state': 'off', }) # --- +# name: test_all_entities[da_ref_normal_000001][switch.refrigerator_sabbath_mode-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': None, + 'entity_id': 'switch.refrigerator_sabbath_mode', + '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': 'Sabbath mode', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'sabbath_mode', + 'unique_id': '7db87911-7dce-1cf2-7119-b953432a2f09_main_samsungce.sabbathMode_status_status', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_ref_normal_000001][switch.refrigerator_sabbath_mode-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Refrigerator Sabbath mode', + }), + 'context': , + 'entity_id': 'switch.refrigerator_sabbath_mode', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- # name: test_all_entities[da_ref_normal_01001][switch.refrigerator_ice_maker-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ From d8122d149b2c6069a64eedb6879d9d7ed66275c2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=2E=20Diego=20Rodr=C3=ADguez=20Royo?= Date: Wed, 30 Apr 2025 15:42:06 +0200 Subject: [PATCH 28/37] Add zeroconf to Home Connect (#143952) --- homeassistant/components/home_connect/manifest.json | 3 ++- homeassistant/generated/zeroconf.py | 5 +++++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/home_connect/manifest.json b/homeassistant/components/home_connect/manifest.json index c5e277c4974bf9..29fd4bfb3fe8e2 100644 --- a/homeassistant/components/home_connect/manifest.json +++ b/homeassistant/components/home_connect/manifest.json @@ -8,5 +8,6 @@ "iot_class": "cloud_push", "loggers": ["aiohomeconnect"], "requirements": ["aiohomeconnect==0.17.0"], - "single_config_entry": true + "single_config_entry": true, + "zeroconf": ["_homeconnect._tcp.local."] } diff --git a/homeassistant/generated/zeroconf.py b/homeassistant/generated/zeroconf.py index a202ebf0f60f4b..38f906636015aa 100644 --- a/homeassistant/generated/zeroconf.py +++ b/homeassistant/generated/zeroconf.py @@ -525,6 +525,11 @@ "domain": "homekit_controller", }, ], + "_homeconnect._tcp.local.": [ + { + "domain": "home_connect", + }, + ], "_homekit._tcp.local.": [ { "domain": "homekit", From fa1dc7551719bf7781109279cb98c505f2a2246d Mon Sep 17 00:00:00 2001 From: Maciej Bieniek Date: Wed, 30 Apr 2025 15:43:07 +0200 Subject: [PATCH 29/37] Add repair flow for Shelly BLE scanner with unsupported firmware (#143850) --- homeassistant/components/shelly/__init__.py | 5 + homeassistant/components/shelly/const.py | 4 + homeassistant/components/shelly/repairs.py | 127 ++++++++++++++++++ homeassistant/components/shelly/strings.json | 15 +++ tests/components/shelly/conftest.py | 1 + tests/components/shelly/test_init.py | 28 +++- tests/components/shelly/test_repairs.py | 131 +++++++++++++++++++ 7 files changed, 310 insertions(+), 1 deletion(-) create mode 100644 homeassistant/components/shelly/repairs.py create mode 100644 tests/components/shelly/test_repairs.py diff --git a/homeassistant/components/shelly/__init__.py b/homeassistant/components/shelly/__init__.py index b6464bd07ba46a..3130acff538f42 100644 --- a/homeassistant/components/shelly/__init__.py +++ b/homeassistant/components/shelly/__init__.py @@ -56,6 +56,7 @@ ShellyRpcCoordinator, ShellyRpcPollingCoordinator, ) +from .repairs import async_manage_ble_scanner_firmware_unsupported_issue from .utils import ( async_create_issue_unsupported_firmware, get_coap_context, @@ -320,6 +321,10 @@ async def _async_setup_rpc_entry(hass: HomeAssistant, entry: ShellyConfigEntry) await hass.config_entries.async_forward_entry_setups( entry, runtime_data.platforms ) + async_manage_ble_scanner_firmware_unsupported_issue( + hass, + entry, + ) elif ( sleep_period is None or device_entry is None diff --git a/homeassistant/components/shelly/const.py b/homeassistant/components/shelly/const.py index cc3ec564b3f19b..87fc50a6666883 100644 --- a/homeassistant/components/shelly/const.py +++ b/homeassistant/components/shelly/const.py @@ -227,6 +227,8 @@ class BLEScannerMode(StrEnum): PASSIVE = "passive" +BLE_SCANNER_MIN_FIRMWARE = "1.5.1" + MAX_PUSH_UPDATE_FAILURES = 5 PUSH_UPDATE_ISSUE_ID = "push_update_{unique}" @@ -234,6 +236,8 @@ class BLEScannerMode(StrEnum): FIRMWARE_UNSUPPORTED_ISSUE_ID = "firmware_unsupported_{unique}" +BLE_SCANNER_FIRMWARE_UNSUPPORTED_ISSUE_ID = "ble_scanner_firmware_unsupported_{unique}" + GAS_VALVE_OPEN_STATES = ("opening", "opened") OTA_BEGIN = "ota_begin" diff --git a/homeassistant/components/shelly/repairs.py b/homeassistant/components/shelly/repairs.py new file mode 100644 index 00000000000000..c39f619fc6c578 --- /dev/null +++ b/homeassistant/components/shelly/repairs.py @@ -0,0 +1,127 @@ +"""Repairs flow for Shelly.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from aioshelly.const import MODEL_OUT_PLUG_S_G3, MODEL_PLUG_S_G3 +from aioshelly.exceptions import DeviceConnectionError, RpcCallError +from aioshelly.rpc_device import RpcDevice +from awesomeversion import AwesomeVersion +import voluptuous as vol + +from homeassistant import data_entry_flow +from homeassistant.components.repairs import RepairsFlow +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import issue_registry as ir + +from .const import ( + BLE_SCANNER_FIRMWARE_UNSUPPORTED_ISSUE_ID, + BLE_SCANNER_MIN_FIRMWARE, + CONF_BLE_SCANNER_MODE, + DOMAIN, + BLEScannerMode, +) +from .coordinator import ShellyConfigEntry + + +@callback +def async_manage_ble_scanner_firmware_unsupported_issue( + hass: HomeAssistant, + entry: ShellyConfigEntry, +) -> None: + """Manage the BLE scanner firmware unsupported issue.""" + issue_id = BLE_SCANNER_FIRMWARE_UNSUPPORTED_ISSUE_ID.format(unique=entry.unique_id) + + if TYPE_CHECKING: + assert entry.runtime_data.rpc is not None + + device = entry.runtime_data.rpc.device + supports_scripts = entry.runtime_data.rpc_supports_scripts + + if supports_scripts and device.model not in (MODEL_PLUG_S_G3, MODEL_OUT_PLUG_S_G3): + firmware = AwesomeVersion(device.shelly["ver"]) + if ( + firmware < BLE_SCANNER_MIN_FIRMWARE + and entry.options.get(CONF_BLE_SCANNER_MODE) == BLEScannerMode.ACTIVE + ): + ir.async_create_issue( + hass, + DOMAIN, + issue_id, + is_fixable=True, + is_persistent=True, + severity=ir.IssueSeverity.WARNING, + translation_key="ble_scanner_firmware_unsupported", + translation_placeholders={ + "device_name": device.name, + "ip_address": device.ip_address, + "firmware": firmware, + }, + data={"entry_id": entry.entry_id}, + ) + return + + ir.async_delete_issue(hass, DOMAIN, issue_id) + + +class BleScannerFirmwareUpdateFlow(RepairsFlow): + """Handler for an issue fixing flow.""" + + def __init__(self, device: RpcDevice) -> None: + """Initialize.""" + self._device = device + + async def async_step_init( + self, user_input: dict[str, str] | None = None + ) -> data_entry_flow.FlowResult: + """Handle the first step of a fix flow.""" + return await self.async_step_confirm() + + async def async_step_confirm( + self, user_input: dict[str, str] | None = None + ) -> data_entry_flow.FlowResult: + """Handle the confirm step of a fix flow.""" + if user_input is not None: + return await self.async_step_update_firmware() + + issue_registry = ir.async_get(self.hass) + description_placeholders = None + if issue := issue_registry.async_get_issue(self.handler, self.issue_id): + description_placeholders = issue.translation_placeholders + + return self.async_show_form( + step_id="confirm", + data_schema=vol.Schema({}), + description_placeholders=description_placeholders, + ) + + async def async_step_update_firmware( + self, user_input: dict[str, str] | None = None + ) -> data_entry_flow.FlowResult: + """Handle the confirm step of a fix flow.""" + if not self._device.status["sys"]["available_updates"]: + return self.async_abort(reason="update_not_available") + try: + await self._device.trigger_ota_update() + except (DeviceConnectionError, RpcCallError): + return self.async_abort(reason="cannot_connect") + + return self.async_create_entry(title="", data={}) + + +async def async_create_fix_flow( + hass: HomeAssistant, issue_id: str, data: dict[str, str] | None +) -> RepairsFlow: + """Create flow.""" + if TYPE_CHECKING: + assert isinstance(data, dict) + + entry_id = data["entry_id"] + entry = hass.config_entries.async_get_entry(entry_id) + + if TYPE_CHECKING: + assert entry is not None + + device = entry.runtime_data.rpc.device + return BleScannerFirmwareUpdateFlow(device) diff --git a/homeassistant/components/shelly/strings.json b/homeassistant/components/shelly/strings.json index b8263e6c292e96..bc6f44a971b827 100644 --- a/homeassistant/components/shelly/strings.json +++ b/homeassistant/components/shelly/strings.json @@ -262,6 +262,21 @@ } }, "issues": { + "ble_scanner_firmware_unsupported": { + "title": "{device_name} is running unsupported firmware", + "fix_flow": { + "step": { + "confirm": { + "title": "{device_name} is running unsupported firmware", + "description": "Your Shelly device {device_name} with IP address {ip_address} is running firmware {firmware} and acts as BLE scanner with active mode. This firmware version is not supported for BLE scanner active mode.\n\nSelect **Submit** button to start the OTA update to the latest stable firmware version." + } + }, + "abort": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "update_not_available": "Device does not offer firmware update. Check internet connectivity (gateway, DNS, time) and restart the device." + } + } + }, "device_not_calibrated": { "title": "Shelly device {device_name} is not calibrated", "description": "Shelly device {device_name} with IP address {ip_address} requires calibration. To calibrate the device, it must be rebooted after proper installation on the valve. You can reboot the device in its web panel, go to 'Settings' > 'Device Reboot'." diff --git a/tests/components/shelly/conftest.py b/tests/components/shelly/conftest.py index a2624f4c070a00..dd17fe34cc862f 100644 --- a/tests/components/shelly/conftest.py +++ b/tests/components/shelly/conftest.py @@ -499,6 +499,7 @@ def _mock_rpc_device(version: str | None = None): ), xmod_info={}, zigbee_enabled=False, + ip_address="10.10.10.10", ) type(device).name = PropertyMock(return_value="Test name") return device diff --git a/tests/components/shelly/test_init.py b/tests/components/shelly/test_init.py index 129aa812580d6a..4cf49a2dab8ba5 100644 --- a/tests/components/shelly/test_init.py +++ b/tests/components/shelly/test_init.py @@ -16,6 +16,8 @@ import pytest from homeassistant.components.shelly.const import ( + BLE_SCANNER_FIRMWARE_UNSUPPORTED_ISSUE_ID, + BLE_SCANNER_MIN_FIRMWARE, BLOCK_EXPECTED_SLEEP_PERIOD, BLOCK_WRONG_SLEEP_PERIOD, CONF_BLE_SCANNER_MODE, @@ -38,7 +40,7 @@ from homeassistant.helpers.device_registry import DeviceRegistry, format_mac from homeassistant.setup import async_setup_component -from . import init_integration, mutate_rpc_device_status +from . import MOCK_MAC, init_integration, mutate_rpc_device_status async def test_custom_coap_port( @@ -579,3 +581,27 @@ async def test_device_script_getcode_error( entry = await init_integration(hass, 2) assert entry.state is ConfigEntryState.SETUP_RETRY + + +async def test_ble_scanner_unsupported_firmware_fixed( + hass: HomeAssistant, + mock_rpc_device: Mock, + monkeypatch: pytest.MonkeyPatch, + issue_registry: ir.IssueRegistry, +) -> None: + """Test device init with unsupported firmware.""" + issue_id = BLE_SCANNER_FIRMWARE_UNSUPPORTED_ISSUE_ID.format(unique=MOCK_MAC) + entry = await init_integration( + hass, 2, options={CONF_BLE_SCANNER_MODE: BLEScannerMode.ACTIVE} + ) + + assert issue_registry.async_get_issue(DOMAIN, issue_id) + assert len(issue_registry.issues) == 1 + + monkeypatch.setitem(mock_rpc_device.shelly, "ver", BLE_SCANNER_MIN_FIRMWARE) + + await hass.config_entries.async_reload(entry.entry_id) + await hass.async_block_till_done() + + assert not issue_registry.async_get_issue(DOMAIN, issue_id) + assert len(issue_registry.issues) == 0 diff --git a/tests/components/shelly/test_repairs.py b/tests/components/shelly/test_repairs.py new file mode 100644 index 00000000000000..f68d2f82f1ba17 --- /dev/null +++ b/tests/components/shelly/test_repairs.py @@ -0,0 +1,131 @@ +"""Test repairs handling for Shelly.""" + +from unittest.mock import Mock + +from aioshelly.exceptions import DeviceConnectionError, RpcCallError +import pytest + +from homeassistant.components.shelly.const import ( + BLE_SCANNER_FIRMWARE_UNSUPPORTED_ISSUE_ID, + CONF_BLE_SCANNER_MODE, + DOMAIN, + BLEScannerMode, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers import issue_registry as ir +from homeassistant.setup import async_setup_component + +from . import MOCK_MAC, init_integration + +from tests.components.repairs import ( + async_process_repairs_platforms, + process_repair_fix_flow, + start_repair_fix_flow, +) +from tests.typing import ClientSessionGenerator + + +async def test_ble_scanner_unsupported_firmware_issue( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + mock_rpc_device: Mock, + issue_registry: ir.IssueRegistry, +) -> None: + """Test repair issues handling for BLE scanner with unsupported firmware.""" + issue_id = BLE_SCANNER_FIRMWARE_UNSUPPORTED_ISSUE_ID.format(unique=MOCK_MAC) + assert await async_setup_component(hass, "repairs", {}) + await hass.async_block_till_done() + await init_integration( + hass, 2, options={CONF_BLE_SCANNER_MODE: BLEScannerMode.ACTIVE} + ) + + assert issue_registry.async_get_issue(DOMAIN, issue_id) + assert len(issue_registry.issues) == 1 + + await async_process_repairs_platforms(hass) + client = await hass_client() + result = await start_repair_fix_flow(client, DOMAIN, issue_id) + + flow_id = result["flow_id"] + assert result["step_id"] == "confirm" + + result = await process_repair_fix_flow(client, flow_id) + assert result["type"] == "create_entry" + assert mock_rpc_device.trigger_ota_update.call_count == 1 + + # Assert the issue is no longer present + assert not issue_registry.async_get_issue(DOMAIN, issue_id) + assert len(issue_registry.issues) == 0 + + +async def test_unsupported_firmware_issue_update_not_available( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + mock_rpc_device: Mock, + monkeypatch: pytest.MonkeyPatch, + issue_registry: ir.IssueRegistry, +) -> None: + """Test repair issues handling when firmware update is not available.""" + issue_id = BLE_SCANNER_FIRMWARE_UNSUPPORTED_ISSUE_ID.format(unique=MOCK_MAC) + assert await async_setup_component(hass, "repairs", {}) + await hass.async_block_till_done() + await init_integration( + hass, 2, options={CONF_BLE_SCANNER_MODE: BLEScannerMode.ACTIVE} + ) + + assert issue_registry.async_get_issue(DOMAIN, issue_id) + assert len(issue_registry.issues) == 1 + + await async_process_repairs_platforms(hass) + client = await hass_client() + result = await start_repair_fix_flow(client, DOMAIN, issue_id) + + flow_id = result["flow_id"] + assert result["step_id"] == "confirm" + + monkeypatch.setitem(mock_rpc_device.status, "sys", {"available_updates": {}}) + result = await process_repair_fix_flow(client, flow_id) + assert result["type"] == "abort" + assert result["reason"] == "update_not_available" + assert mock_rpc_device.trigger_ota_update.call_count == 0 + + assert issue_registry.async_get_issue(DOMAIN, issue_id) + assert len(issue_registry.issues) == 1 + + +@pytest.mark.parametrize( + "exception", [DeviceConnectionError, RpcCallError(999, "Unknown error")] +) +async def test_unsupported_firmware_issue_exc( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + mock_rpc_device: Mock, + issue_registry: ir.IssueRegistry, + exception: Exception, +) -> None: + """Test repair issues handling when OTA update ends with an exception.""" + issue_id = BLE_SCANNER_FIRMWARE_UNSUPPORTED_ISSUE_ID.format(unique=MOCK_MAC) + assert await async_setup_component(hass, "repairs", {}) + await hass.async_block_till_done() + await init_integration( + hass, 2, options={CONF_BLE_SCANNER_MODE: BLEScannerMode.ACTIVE} + ) + + assert issue_registry.async_get_issue(DOMAIN, issue_id) + assert len(issue_registry.issues) == 1 + + await async_process_repairs_platforms(hass) + client = await hass_client() + result = await start_repair_fix_flow(client, DOMAIN, issue_id) + + flow_id = result["flow_id"] + assert result["step_id"] == "confirm" + + mock_rpc_device.trigger_ota_update.side_effect = exception + result = await process_repair_fix_flow(client, flow_id) + assert result["type"] == "abort" + assert result["reason"] == "cannot_connect" + assert mock_rpc_device.trigger_ota_update.call_count == 1 + + assert issue_registry.async_get_issue(DOMAIN, issue_id) + assert len(issue_registry.issues) == 1 From 84634ce288c4daf8e0e482968065254c23ee76a2 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Wed, 30 Apr 2025 15:56:22 +0200 Subject: [PATCH 30/37] Improve Error message states in `fronius` (#143958) --- homeassistant/components/fronius/strings.json | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/fronius/strings.json b/homeassistant/components/fronius/strings.json index e37607452e31af..7c42cca29de67a 100644 --- a/homeassistant/components/fronius/strings.json +++ b/homeassistant/components/fronius/strings.json @@ -82,13 +82,13 @@ "ac_frequency_too_high": "AC frequency too high", "ac_frequency_too_low": "AC frequency too low", "ac_grid_outside_permissible_limits": "AC grid outside the permissible limits", - "stand_alone_operation_detected": "Stand alone operation detected", + "stand_alone_operation_detected": "Stand-alone operation detected", "rcmu_error": "RCMU error", "arc_detection_triggered": "Arc detection triggered", "overcurrent_ac": "Overcurrent (AC)", "overcurrent_dc": "Overcurrent (DC)", - "dc_module_over_temperature": "DC module over temperature", - "ac_module_over_temperature": "AC module over temperature", + "dc_module_over_temperature": "DC module overtemperature", + "ac_module_over_temperature": "AC module overtemperature", "no_power_fed_in_despite_closed_relay": "No power being fed in, despite closed relay", "pv_output_too_low_for_feeding_energy_into_the_grid": "PV output too low for feeding energy into the grid", "low_pv_voltage_dc_input_voltage_too_low": "Low PV voltage - DC input voltage too low for feeding energy into the grid", @@ -133,16 +133,16 @@ "no_energy_fed_by_mppt1_past_24_hours": "No energy fed into the grid by MPPT1 in the past 24 hours", "dc_low_string_1": "DC low string 1", "dc_low_string_2": "DC low string 2", - "derating_caused_by_over_frequency": "Derating caused by over-frequency", + "derating_caused_by_over_frequency": "Derating caused by overfrequency", "arc_detector_switched_off": "Arc detector switched off (e.g. during external arc monitoring)", - "grid_voltage_dependent_power_reduction_active": "Grid Voltage Dependent Power Reduction is active", + "grid_voltage_dependent_power_reduction_active": "Grid voltage-dependent power reduction (GVDPR) is active", "can_bus_full": "CAN bus is full", "ac_module_temperature_sensor_faulty_l3": "AC module temperature sensor faulty (L3)", "dc_module_temperature_sensor_faulty": "DC module temperature sensor faulty", "internal_processor_status": "Warning about the internal processor status. See status code for more information", "eeprom_reinitialised": "EEPROM has been re-initialised", "initialisation_error_usb_flash_drive_not_supported": "Initialisation error – USB flash drive is not supported", - "initialisation_error_usb_stick_over_current": "Initialisation error – Over current on USB stick", + "initialisation_error_usb_stick_over_current": "Initialisation error – Overcurrent on USB stick", "no_usb_flash_drive_connected": "No USB flash drive connected", "update_file_not_recognised_or_missing": "Update file not recognised or not present", "update_file_does_not_match_device": "Update file does not match the device, update file too old", From 5b0ea216079aa626c3f8c252b9ab19aa8481fa71 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Wed, 30 Apr 2025 15:57:51 +0200 Subject: [PATCH 31/37] Add light as entity platform on MQTT subentries (#141345) * Add light as entity platform on MQTT subentries * Improve translation strings * Rename to separate brightness * Remove option to use mireds for color temperature * Fix tests * Add translation reference * Correct reference * Add flash and transition feature switches --------- Co-authored-by: Erik Montnemery --- homeassistant/components/mqtt/config_flow.py | 681 +++++++++++++++++- homeassistant/components/mqtt/const.py | 2 + .../components/mqtt/light/schema_basic.py | 3 +- homeassistant/components/mqtt/strings.json | 254 ++++++- tests/components/mqtt/common.py | 113 +-- tests/components/mqtt/test_config_flow.py | 90 ++- 6 files changed, 1032 insertions(+), 111 deletions(-) diff --git a/homeassistant/components/mqtt/config_flow.py b/homeassistant/components/mqtt/config_flow.py index ecb7d9cfeb1f0e..1f317d9f743efe 100644 --- a/homeassistant/components/mqtt/config_flow.py +++ b/homeassistant/components/mqtt/config_flow.py @@ -27,6 +27,12 @@ from homeassistant.components.file_upload import process_uploaded_file from homeassistant.components.hassio import AddonError, AddonManager, AddonState +from homeassistant.components.light import ( + DEFAULT_MAX_KELVIN, + DEFAULT_MIN_KELVIN, + VALID_COLOR_MODES, + valid_supported_color_modes, +) from homeassistant.components.sensor import ( CONF_STATE_CLASS, DEVICE_CLASS_UNITS, @@ -50,18 +56,23 @@ ATTR_MODEL_ID, ATTR_NAME, ATTR_SW_VERSION, + CONF_BRIGHTNESS, CONF_CLIENT_ID, CONF_DEVICE, CONF_DEVICE_CLASS, CONF_DISCOVERY, + CONF_EFFECT, CONF_HOST, CONF_NAME, CONF_OPTIMISTIC, CONF_PASSWORD, CONF_PAYLOAD, + CONF_PAYLOAD_OFF, + CONF_PAYLOAD_ON, CONF_PLATFORM, CONF_PORT, CONF_PROTOCOL, + CONF_STATE_TEMPLATE, CONF_UNIT_OF_MEASUREMENT, CONF_USERNAME, CONF_VALUE_TEMPLATE, @@ -102,37 +113,97 @@ CONF_AVAILABILITY_TEMPLATE, CONF_AVAILABILITY_TOPIC, CONF_BIRTH_MESSAGE, + CONF_BLUE_TEMPLATE, + CONF_BRIGHTNESS_COMMAND_TEMPLATE, + CONF_BRIGHTNESS_COMMAND_TOPIC, + CONF_BRIGHTNESS_SCALE, + CONF_BRIGHTNESS_STATE_TOPIC, + CONF_BRIGHTNESS_TEMPLATE, + CONF_BRIGHTNESS_VALUE_TEMPLATE, CONF_BROKER, CONF_CERTIFICATE, CONF_CLIENT_CERT, CONF_CLIENT_KEY, + CONF_COLOR_MODE_STATE_TOPIC, + CONF_COLOR_MODE_VALUE_TEMPLATE, + CONF_COLOR_TEMP_COMMAND_TEMPLATE, + CONF_COLOR_TEMP_COMMAND_TOPIC, + CONF_COLOR_TEMP_KELVIN, + CONF_COLOR_TEMP_STATE_TOPIC, + CONF_COLOR_TEMP_TEMPLATE, + CONF_COLOR_TEMP_VALUE_TEMPLATE, + CONF_COMMAND_OFF_TEMPLATE, + CONF_COMMAND_ON_TEMPLATE, CONF_COMMAND_TEMPLATE, CONF_COMMAND_TOPIC, CONF_DISCOVERY_PREFIX, + CONF_EFFECT_COMMAND_TEMPLATE, + CONF_EFFECT_COMMAND_TOPIC, + CONF_EFFECT_LIST, + CONF_EFFECT_STATE_TOPIC, + CONF_EFFECT_TEMPLATE, + CONF_EFFECT_VALUE_TEMPLATE, CONF_ENTITY_PICTURE, CONF_EXPIRE_AFTER, + CONF_FLASH, + CONF_FLASH_TIME_LONG, + CONF_FLASH_TIME_SHORT, + CONF_GREEN_TEMPLATE, + CONF_HS_COMMAND_TEMPLATE, + CONF_HS_COMMAND_TOPIC, + CONF_HS_STATE_TOPIC, + CONF_HS_VALUE_TEMPLATE, CONF_KEEPALIVE, CONF_LAST_RESET_VALUE_TEMPLATE, + CONF_MAX_KELVIN, + CONF_MIN_KELVIN, + CONF_ON_COMMAND_TYPE, CONF_OPTIONS, CONF_PAYLOAD_AVAILABLE, CONF_PAYLOAD_NOT_AVAILABLE, CONF_QOS, + CONF_RED_TEMPLATE, CONF_RETAIN, + CONF_RGB_COMMAND_TEMPLATE, + CONF_RGB_COMMAND_TOPIC, + CONF_RGB_STATE_TOPIC, + CONF_RGB_VALUE_TEMPLATE, + CONF_RGBW_COMMAND_TEMPLATE, + CONF_RGBW_COMMAND_TOPIC, + CONF_RGBW_STATE_TOPIC, + CONF_RGBW_VALUE_TEMPLATE, + CONF_RGBWW_COMMAND_TEMPLATE, + CONF_RGBWW_COMMAND_TOPIC, + CONF_RGBWW_STATE_TOPIC, + CONF_RGBWW_VALUE_TEMPLATE, + CONF_SCHEMA, CONF_STATE_TOPIC, + CONF_STATE_VALUE_TEMPLATE, CONF_SUGGESTED_DISPLAY_PRECISION, + CONF_SUPPORTED_COLOR_MODES, CONF_TLS_INSECURE, + CONF_TRANSITION, CONF_TRANSPORT, + CONF_WHITE_COMMAND_TOPIC, + CONF_WHITE_SCALE, CONF_WILL_MESSAGE, CONF_WS_HEADERS, CONF_WS_PATH, + CONF_XY_COMMAND_TEMPLATE, + CONF_XY_COMMAND_TOPIC, + CONF_XY_STATE_TOPIC, + CONF_XY_VALUE_TEMPLATE, CONFIG_ENTRY_MINOR_VERSION, CONFIG_ENTRY_VERSION, DEFAULT_BIRTH, DEFAULT_DISCOVERY, DEFAULT_ENCODING, DEFAULT_KEEPALIVE, + DEFAULT_ON_COMMAND_TYPE, DEFAULT_PAYLOAD_AVAILABLE, DEFAULT_PAYLOAD_NOT_AVAILABLE, + DEFAULT_PAYLOAD_OFF, + DEFAULT_PAYLOAD_ON, DEFAULT_PORT, DEFAULT_PREFIX, DEFAULT_PROTOCOL, @@ -144,6 +215,7 @@ SUPPORTED_PROTOCOLS, TRANSPORT_TCP, TRANSPORT_WEBSOCKETS, + VALUES_ON_COMMAND_TYPE, Platform, ) from .models import MqttAvailabilityData, MqttDeviceData, MqttSubentryData @@ -233,7 +305,7 @@ ) # Subentry selectors -SUBENTRY_PLATFORMS = [Platform.NOTIFY, Platform.SENSOR, Platform.SWITCH] +SUBENTRY_PLATFORMS = [Platform.LIGHT, Platform.NOTIFY, Platform.SENSOR, Platform.SWITCH] SUBENTRY_PLATFORM_SELECTOR = SelectSelector( SelectSelectorConfig( options=[platform.value for platform in SUBENTRY_PLATFORMS], @@ -295,6 +367,54 @@ ) ) +# Light specific selectors +LIGHT_SCHEMA_SELECTOR = SelectSelector( + SelectSelectorConfig( + options=["basic", "json", "template"], + translation_key="light_schema", + ) +) +KELVIN_SELECTOR = NumberSelector( + NumberSelectorConfig( + mode=NumberSelectorMode.BOX, + min=1000, + max=10000, + step="any", + unit_of_measurement="K", + ) +) +SCALE_SELECTOR = NumberSelector( + NumberSelectorConfig( + mode=NumberSelectorMode.BOX, + min=1, + max=255, + step=1, + ) +) +FLASH_TIME_SELECTOR = NumberSelector( + NumberSelectorConfig( + mode=NumberSelectorMode.BOX, + min=1, + ) +) +ON_COMMAND_TYPE_SELECTOR = SelectSelector( + SelectSelectorConfig( + options=VALUES_ON_COMMAND_TYPE, + mode=SelectSelectorMode.DROPDOWN, + translation_key=CONF_ON_COMMAND_TYPE, + sort=True, + ) +) +SUPPORTED_COLOR_MODES_SELECTOR = SelectSelector( + SelectSelectorConfig( + options=[platform.value for platform in VALID_COLOR_MODES], + mode=SelectSelectorMode.DROPDOWN, + translation_key=CONF_SUPPORTED_COLOR_MODES, + multiple=True, + sort=True, + ) +) + @callback def validate_sensor_platform_config( @@ -345,7 +465,8 @@ class PlatformField: required: bool validator: Callable[..., Any] error: str | None = None - default: str | int | vol.Undefined = vol.UNDEFINED + default: str | int | bool | vol.Undefined = vol.UNDEFINED + is_schema_default: bool = False exclude_from_reconfig: bool = False conditions: tuple[dict[str, Any], ...] | None = None custom_filtering: bool = False @@ -370,6 +491,18 @@ def unit_of_measurement_selector(user_data: dict[str, Any | None]) -> Selector: ) +@callback +def validate_light_platform_config(user_data: dict[str, Any]) -> dict[str, str]: + """Validate MQTT light configuration.""" + errors: dict[str, Any] = {} + if user_data.get(CONF_MIN_KELVIN, DEFAULT_MIN_KELVIN) >= user_data.get( + CONF_MAX_KELVIN, DEFAULT_MAX_KELVIN + ): + errors[CONF_MAX_KELVIN] = "max_below_min_kelvin" + errors[CONF_MIN_KELVIN] = "max_below_min_kelvin" + return errors + + COMMON_ENTITY_FIELDS = { CONF_PLATFORM: PlatformField( selector=SUBENTRY_PLATFORM_SELECTOR, @@ -421,6 +554,22 @@ def unit_of_measurement_selector(user_data: dict[str, Any | None]) -> Selector: selector=SWITCH_DEVICE_CLASS_SELECTOR, required=False, validator=str ), }, + Platform.LIGHT.value: { + CONF_SCHEMA: PlatformField( + selector=LIGHT_SCHEMA_SELECTOR, + required=True, + validator=str, + default="basic", + exclude_from_reconfig=True, + ), + CONF_COLOR_TEMP_KELVIN: PlatformField( + selector=BOOLEAN_SELECTOR, + required=True, + validator=bool, + default=True, + is_schema_default=True, + ), + }, } PLATFORM_MQTT_FIELDS = { Platform.NOTIFY.value: { @@ -499,11 +648,502 @@ def unit_of_measurement_selector(user_data: dict[str, Any | None]) -> Selector: selector=BOOLEAN_SELECTOR, required=False, validator=bool ), }, + Platform.LIGHT.value: { + CONF_COMMAND_TOPIC: PlatformField( + selector=TEXT_SELECTOR, + required=True, + validator=valid_publish_topic, + error="invalid_publish_topic", + ), + CONF_COMMAND_ON_TEMPLATE: PlatformField( + selector=TEMPLATE_SELECTOR, + required=True, + validator=cv.template, + error="invalid_template", + conditions=({CONF_SCHEMA: "template"},), + ), + CONF_COMMAND_OFF_TEMPLATE: PlatformField( + selector=TEMPLATE_SELECTOR, + required=True, + validator=cv.template, + error="invalid_template", + conditions=({CONF_SCHEMA: "template"},), + ), + CONF_ON_COMMAND_TYPE: PlatformField( + selector=ON_COMMAND_TYPE_SELECTOR, + required=False, + validator=str, + default=DEFAULT_ON_COMMAND_TYPE, + conditions=({CONF_SCHEMA: "basic"},), + ), + CONF_STATE_TOPIC: PlatformField( + selector=TEXT_SELECTOR, + required=False, + validator=valid_subscribe_topic, + error="invalid_subscribe_topic", + ), + CONF_STATE_VALUE_TEMPLATE: PlatformField( + selector=TEMPLATE_SELECTOR, + required=False, + validator=cv.template, + error="invalid_template", + conditions=({CONF_SCHEMA: "basic"},), + ), + CONF_STATE_TEMPLATE: PlatformField( + selector=TEMPLATE_SELECTOR, + required=False, + validator=cv.template, + error="invalid_template", + conditions=({CONF_SCHEMA: "template"},), + ), + CONF_SUPPORTED_COLOR_MODES: PlatformField( + selector=SUPPORTED_COLOR_MODES_SELECTOR, + required=False, + validator=valid_supported_color_modes, + error="invalid_supported_color_modes", + conditions=({CONF_SCHEMA: "json"},), + ), + CONF_OPTIMISTIC: PlatformField( + selector=BOOLEAN_SELECTOR, required=False, validator=bool + ), + CONF_RETAIN: PlatformField( + selector=BOOLEAN_SELECTOR, + required=False, + validator=bool, + conditions=({CONF_SCHEMA: "basic"},), + ), + CONF_BRIGHTNESS: PlatformField( + selector=BOOLEAN_SELECTOR, + required=False, + validator=bool, + conditions=({CONF_SCHEMA: "json"},), + section="light_brightness_settings", + ), + CONF_BRIGHTNESS_COMMAND_TOPIC: PlatformField( + selector=TEXT_SELECTOR, + required=False, + validator=valid_publish_topic, + error="invalid_publish_topic", + conditions=({CONF_SCHEMA: "basic"},), + section="light_brightness_settings", + ), + CONF_BRIGHTNESS_COMMAND_TEMPLATE: PlatformField( + selector=TEMPLATE_SELECTOR, + required=False, + validator=cv.template, + error="invalid_template", + conditions=({CONF_SCHEMA: "basic"},), + section="light_brightness_settings", + ), + CONF_BRIGHTNESS_STATE_TOPIC: PlatformField( + selector=TEXT_SELECTOR, + required=False, + validator=valid_subscribe_topic, + error="invalid_subscribe_topic", + conditions=({CONF_SCHEMA: "basic"},), + section="light_brightness_settings", + ), + CONF_PAYLOAD_OFF: PlatformField( + selector=TEXT_SELECTOR, + required=False, + validator=str, + default=DEFAULT_PAYLOAD_OFF, + conditions=({CONF_SCHEMA: "basic"},), + ), + CONF_PAYLOAD_ON: PlatformField( + selector=TEXT_SELECTOR, + required=False, + validator=str, + default=DEFAULT_PAYLOAD_ON, + conditions=({CONF_SCHEMA: "basic"},), + ), + CONF_BRIGHTNESS_VALUE_TEMPLATE: PlatformField( + selector=TEMPLATE_SELECTOR, + required=False, + validator=cv.template, + error="invalid_template", + conditions=({CONF_SCHEMA: "basic"},), + section="light_brightness_settings", + ), + CONF_BRIGHTNESS_SCALE: PlatformField( + selector=SCALE_SELECTOR, + required=False, + validator=cv.positive_int, + default=255, + conditions=( + {CONF_SCHEMA: "basic"}, + {CONF_SCHEMA: "json"}, + ), + section="light_brightness_settings", + ), + CONF_COLOR_MODE_STATE_TOPIC: PlatformField( + selector=TEXT_SELECTOR, + required=False, + validator=valid_subscribe_topic, + error="invalid_subscribe_topic", + conditions=({CONF_SCHEMA: "basic"},), + section="light_color_mode_settings", + ), + CONF_COLOR_MODE_VALUE_TEMPLATE: PlatformField( + selector=TEMPLATE_SELECTOR, + required=False, + validator=cv.template, + error="invalid_template", + conditions=({CONF_SCHEMA: "basic"},), + section="light_color_mode_settings", + ), + CONF_COLOR_TEMP_COMMAND_TOPIC: PlatformField( + selector=TEXT_SELECTOR, + required=False, + validator=valid_publish_topic, + error="invalid_publish_topic", + conditions=({CONF_SCHEMA: "basic"},), + section="light_color_temp_settings", + ), + CONF_COLOR_TEMP_COMMAND_TEMPLATE: PlatformField( + selector=TEMPLATE_SELECTOR, + required=False, + validator=cv.template, + error="invalid_template", + conditions=({CONF_SCHEMA: "basic"},), + section="light_color_temp_settings", + ), + CONF_COLOR_TEMP_STATE_TOPIC: PlatformField( + selector=TEXT_SELECTOR, + required=False, + validator=valid_subscribe_topic, + error="invalid_subscribe_topic", + conditions=({CONF_SCHEMA: "basic"},), + section="light_color_temp_settings", + ), + CONF_COLOR_TEMP_VALUE_TEMPLATE: PlatformField( + selector=TEMPLATE_SELECTOR, + required=False, + validator=cv.template, + error="invalid_template", + conditions=({CONF_SCHEMA: "basic"},), + section="light_color_temp_settings", + ), + CONF_BRIGHTNESS_TEMPLATE: PlatformField( + selector=TEMPLATE_SELECTOR, + required=False, + validator=cv.template, + error="invalid_template", + conditions=({CONF_SCHEMA: "template"},), + ), + CONF_RED_TEMPLATE: PlatformField( + selector=TEMPLATE_SELECTOR, + required=False, + validator=cv.template, + error="invalid_template", + conditions=({CONF_SCHEMA: "template"},), + ), + CONF_GREEN_TEMPLATE: PlatformField( + selector=TEMPLATE_SELECTOR, + required=False, + validator=cv.template, + error="invalid_template", + conditions=({CONF_SCHEMA: "template"},), + ), + CONF_BLUE_TEMPLATE: PlatformField( + selector=TEMPLATE_SELECTOR, + required=False, + validator=cv.template, + error="invalid_template", + conditions=({CONF_SCHEMA: "template"},), + ), + CONF_COLOR_TEMP_TEMPLATE: PlatformField( + selector=TEMPLATE_SELECTOR, + required=False, + validator=cv.template, + error="invalid_template", + conditions=({CONF_SCHEMA: "template"},), + ), + CONF_HS_COMMAND_TOPIC: PlatformField( + selector=TEXT_SELECTOR, + required=False, + validator=valid_publish_topic, + error="invalid_publish_topic", + conditions=({CONF_SCHEMA: "basic"},), + section="light_hs_settings", + ), + CONF_HS_COMMAND_TEMPLATE: PlatformField( + selector=TEMPLATE_SELECTOR, + required=False, + validator=cv.template, + error="invalid_template", + conditions=({CONF_SCHEMA: "basic"},), + section="light_hs_settings", + ), + CONF_HS_STATE_TOPIC: PlatformField( + selector=TEXT_SELECTOR, + required=False, + validator=valid_subscribe_topic, + error="invalid_subscribe_topic", + conditions=({CONF_SCHEMA: "basic"},), + section="light_hs_settings", + ), + CONF_HS_VALUE_TEMPLATE: PlatformField( + selector=TEMPLATE_SELECTOR, + required=False, + validator=cv.template, + error="invalid_template", + conditions=({CONF_SCHEMA: "basic"},), + section="light_hs_settings", + ), + CONF_RGB_COMMAND_TOPIC: PlatformField( + selector=TEXT_SELECTOR, + required=False, + validator=valid_publish_topic, + error="invalid_publish_topic", + conditions=({CONF_SCHEMA: "basic"},), + section="light_rgb_settings", + ), + CONF_RGB_COMMAND_TEMPLATE: PlatformField( + selector=TEMPLATE_SELECTOR, + required=False, + validator=cv.template, + error="invalid_template", + conditions=({CONF_SCHEMA: "basic"},), + section="light_rgb_settings", + ), + CONF_RGB_STATE_TOPIC: PlatformField( + selector=TEXT_SELECTOR, + required=False, + validator=valid_subscribe_topic, + error="invalid_subscribe_topic", + conditions=({CONF_SCHEMA: "basic"},), + section="light_rgb_settings", + ), + CONF_RGB_VALUE_TEMPLATE: PlatformField( + selector=TEMPLATE_SELECTOR, + required=False, + validator=cv.template, + error="invalid_template", + conditions=({CONF_SCHEMA: "basic"},), + section="light_rgb_settings", + ), + CONF_RGBW_COMMAND_TOPIC: PlatformField( + selector=TEXT_SELECTOR, + required=False, + validator=valid_publish_topic, + error="invalid_publish_topic", + conditions=({CONF_SCHEMA: "basic"},), + section="light_rgbw_settings", + ), + CONF_RGBW_COMMAND_TEMPLATE: PlatformField( + selector=TEMPLATE_SELECTOR, + required=False, + validator=cv.template, + error="invalid_template", + conditions=({CONF_SCHEMA: "basic"},), + section="light_rgbw_settings", + ), + CONF_RGBW_STATE_TOPIC: PlatformField( + selector=TEXT_SELECTOR, + required=False, + validator=valid_subscribe_topic, + error="invalid_subscribe_topic", + conditions=({CONF_SCHEMA: "basic"},), + section="light_rgbw_settings", + ), + CONF_RGBW_VALUE_TEMPLATE: PlatformField( + selector=TEMPLATE_SELECTOR, + required=False, + validator=cv.template, + error="invalid_template", + conditions=({CONF_SCHEMA: "basic"},), + section="light_rgbw_settings", + ), + CONF_RGBWW_COMMAND_TOPIC: PlatformField( + selector=TEXT_SELECTOR, + required=False, + validator=valid_publish_topic, + error="invalid_publish_topic", + conditions=({CONF_SCHEMA: "basic"},), + section="light_rgbww_settings", + ), + CONF_RGBWW_COMMAND_TEMPLATE: PlatformField( + selector=TEMPLATE_SELECTOR, + required=False, + validator=cv.template, + error="invalid_template", + conditions=({CONF_SCHEMA: "basic"},), + section="light_rgbww_settings", + ), + CONF_RGBWW_STATE_TOPIC: PlatformField( + selector=TEXT_SELECTOR, + required=False, + validator=valid_subscribe_topic, + error="invalid_subscribe_topic", + conditions=({CONF_SCHEMA: "basic"},), + section="light_rgbww_settings", + ), + CONF_RGBWW_VALUE_TEMPLATE: PlatformField( + selector=TEMPLATE_SELECTOR, + required=False, + validator=cv.template, + error="invalid_template", + conditions=({CONF_SCHEMA: "basic"},), + section="light_rgbww_settings", + ), + CONF_XY_COMMAND_TOPIC: PlatformField( + selector=TEXT_SELECTOR, + required=False, + validator=valid_publish_topic, + error="invalid_publish_topic", + conditions=({CONF_SCHEMA: "basic"},), + section="light_xy_settings", + ), + CONF_XY_COMMAND_TEMPLATE: PlatformField( + selector=TEMPLATE_SELECTOR, + required=False, + validator=cv.template, + error="invalid_template", + conditions=({CONF_SCHEMA: "basic"},), + section="light_xy_settings", + ), + CONF_XY_STATE_TOPIC: PlatformField( + selector=TEXT_SELECTOR, + required=False, + validator=valid_subscribe_topic, + error="invalid_subscribe_topic", + conditions=({CONF_SCHEMA: "basic"},), + section="light_xy_settings", + ), + CONF_XY_VALUE_TEMPLATE: PlatformField( + selector=TEMPLATE_SELECTOR, + required=False, + validator=cv.template, + error="invalid_template", + conditions=({CONF_SCHEMA: "basic"},), + section="light_xy_settings", + ), + CONF_WHITE_COMMAND_TOPIC: PlatformField( + selector=TEXT_SELECTOR, + required=False, + validator=valid_publish_topic, + error="invalid_publish_topic", + conditions=({CONF_SCHEMA: "basic"},), + section="light_white_settings", + ), + CONF_WHITE_SCALE: PlatformField( + selector=SCALE_SELECTOR, + required=False, + validator=cv.positive_int, + default=255, + conditions=( + {CONF_SCHEMA: "basic"}, + {CONF_SCHEMA: "json"}, + ), + section="light_white_settings", + ), + CONF_EFFECT: PlatformField( + selector=BOOLEAN_SELECTOR, + required=False, + validator=bool, + conditions=({CONF_SCHEMA: "json"},), + section="light_effect_settings", + ), + CONF_EFFECT_COMMAND_TOPIC: PlatformField( + selector=TEXT_SELECTOR, + required=False, + validator=valid_publish_topic, + error="invalid_publish_topic", + conditions=({CONF_SCHEMA: "basic"},), + section="light_effect_settings", + ), + CONF_EFFECT_COMMAND_TEMPLATE: PlatformField( + selector=TEMPLATE_SELECTOR, + required=False, + validator=cv.template, + error="invalid_template", + conditions=({CONF_SCHEMA: "basic"},), + section="light_effect_settings", + ), + CONF_EFFECT_STATE_TOPIC: PlatformField( + selector=TEXT_SELECTOR, + required=False, + validator=valid_subscribe_topic, + error="invalid_subscribe_topic", + conditions=({CONF_SCHEMA: "basic"},), + section="light_effect_settings", + ), + CONF_EFFECT_TEMPLATE: PlatformField( + selector=TEMPLATE_SELECTOR, + required=False, + validator=cv.template, + error="invalid_template", + conditions=({CONF_SCHEMA: "template"},), + section="light_effect_settings", + ), + CONF_EFFECT_VALUE_TEMPLATE: PlatformField( + selector=TEMPLATE_SELECTOR, + required=False, + validator=cv.template, + error="invalid_template", + conditions=({CONF_SCHEMA: "basic"},), + section="light_effect_settings", + ), + CONF_EFFECT_LIST: PlatformField( + selector=OPTIONS_SELECTOR, + required=False, + validator=cv.ensure_list, + section="light_effect_settings", + ), + CONF_FLASH: PlatformField( + selector=BOOLEAN_SELECTOR, + required=False, + default=False, + validator=cv.boolean, + conditions=({CONF_SCHEMA: "json"},), + section="advanced_settings", + ), + CONF_FLASH_TIME_SHORT: PlatformField( + selector=FLASH_TIME_SELECTOR, + required=False, + validator=cv.positive_int, + default=2, + conditions=({CONF_SCHEMA: "json"},), + section="advanced_settings", + ), + CONF_FLASH_TIME_LONG: PlatformField( + selector=FLASH_TIME_SELECTOR, + required=False, + validator=cv.positive_int, + default=10, + conditions=({CONF_SCHEMA: "json"},), + section="advanced_settings", + ), + CONF_TRANSITION: PlatformField( + selector=BOOLEAN_SELECTOR, + required=False, + default=False, + validator=cv.boolean, + conditions=({CONF_SCHEMA: "json"},), + section="advanced_settings", + ), + CONF_MAX_KELVIN: PlatformField( + selector=KELVIN_SELECTOR, + required=False, + validator=cv.positive_int, + default=DEFAULT_MAX_KELVIN, + section="advanced_settings", + ), + CONF_MIN_KELVIN: PlatformField( + selector=KELVIN_SELECTOR, + required=False, + validator=cv.positive_int, + default=DEFAULT_MIN_KELVIN, + section="advanced_settings", + ), + }, } ENTITY_CONFIG_VALIDATOR: dict[ str, Callable[[dict[str, Any]], dict[str, str]] | None, ] = { + Platform.LIGHT.value: validate_light_platform_config, Platform.NOTIFY.value: None, Platform.SENSOR.value: validate_sensor_platform_config, Platform.SWITCH.value: None, @@ -576,7 +1216,7 @@ def validate_field( return try: validator(user_input[field]) - except (ValueError, vol.Invalid): + except (ValueError, vol.Error, vol.Invalid): errors[field] = error @@ -634,7 +1274,7 @@ def validate_user_input( validator = data_schema_fields[field].validator try: validator(value) - except (ValueError, vol.Invalid): + except (ValueError, vol.Error, vol.Invalid): errors[field] = data_schema_fields[field].error or "invalid_input" if config_validator is not None: @@ -672,7 +1312,9 @@ def data_schema_from_fields( component_data_with_user_input |= user_input sections: dict[str | None, None] = { - field_details.section: None for field_details in data_schema_fields.values() + field_details.section: None + for field_details in data_schema_fields.values() + if not field_details.is_schema_default } data_schema: dict[Any, Any] = {} all_data_element_options: set[Any] = set() @@ -687,7 +1329,8 @@ def data_schema_from_fields( if field_details.custom_filtering else field_details.selector for field_name, field_details in data_schema_fields.items() - if field_details.section == schema_section + if not field_details.is_schema_default + and field_details.section == schema_section and (not field_details.exclude_from_reconfig or not reconfig) and _check_conditions(field_details, component_data_with_user_input) } @@ -699,6 +1342,8 @@ def data_schema_from_fields( if field_details.section == schema_section and field_details.exclude_from_reconfig } + if not data_element_options: + continue if schema_section is None: data_schema.update(data_schema_element) continue @@ -727,6 +1372,18 @@ def data_schema_from_fields( return vol.Schema(data_schema) +@callback +def subentry_schema_default_data_from_fields( + data_schema_fields: dict[str, PlatformField], +) -> dict[str, Any]: + """Generate custom data schema from platform fields or device data.""" + return { + key: field.default + for key, field in data_schema_fields.items() + if field.is_schema_default + } + + class FlowHandler(ConfigFlow, domain=DOMAIN): """Handle a config flow.""" @@ -1543,6 +2200,16 @@ async def async_step_mqtt_platform_config( last_step=False, ) + @callback + def _async_update_component_data_defaults(self) -> None: + """Update component data defaults.""" + for component_data in self._subentry_data["components"].values(): + platform = component_data[CONF_PLATFORM] + subentry_default_data = subentry_schema_default_data_from_fields( + PLATFORM_ENTITY_FIELDS[platform] + ) + component_data.update(subentry_default_data) + @callback def _async_create_subentry( self, user_input: dict[str, Any] | None = None @@ -1559,6 +2226,7 @@ def _async_create_subentry( else: full_entity_name = device_name + self._async_update_component_data_defaults() return self.async_create_entry( data=self._subentry_data, title=self._subentry_data[CONF_DEVICE][CONF_NAME], @@ -1623,6 +2291,7 @@ async def async_step_summary_menu( if len(self._subentry_data["components"]) > 1: menu_options.append("delete_entity") menu_options.extend(["device", "availability"]) + self._async_update_component_data_defaults() if self._subentry_data != self._get_reconfigure_subentry().data: menu_options.append("save_changes") return self.async_show_menu( diff --git a/homeassistant/components/mqtt/const.py b/homeassistant/components/mqtt/const.py index 090fc74aa8822b..18107c5c93992f 100644 --- a/homeassistant/components/mqtt/const.py +++ b/homeassistant/components/mqtt/const.py @@ -196,6 +196,8 @@ DEFAULT_RETAIN = False DEFAULT_WHITE_SCALE = 255 +VALUES_ON_COMMAND_TYPE = ["first", "last", "brightness"] + PROTOCOL_31 = "3.1" PROTOCOL_311 = "3.1.1" PROTOCOL_5 = "5" diff --git a/homeassistant/components/mqtt/light/schema_basic.py b/homeassistant/components/mqtt/light/schema_basic.py index a950aced6657d3..61a55d64049eda 100644 --- a/homeassistant/components/mqtt/light/schema_basic.py +++ b/homeassistant/components/mqtt/light/schema_basic.py @@ -104,6 +104,7 @@ DEFAULT_PAYLOAD_ON, DEFAULT_WHITE_SCALE, PAYLOAD_NONE, + VALUES_ON_COMMAND_TYPE, ) from ..entity import MqttEntity from ..models import ( @@ -143,8 +144,6 @@ } ) -VALUES_ON_COMMAND_TYPE = ["first", "last", "brightness"] - COMMAND_TEMPLATE_KEYS = [ CONF_BRIGHTNESS_COMMAND_TEMPLATE, CONF_COLOR_TEMP_COMMAND_TEMPLATE, diff --git a/homeassistant/components/mqtt/strings.json b/homeassistant/components/mqtt/strings.json index 4245af2fc95907..b94144e3835042 100644 --- a/homeassistant/components/mqtt/strings.json +++ b/homeassistant/components/mqtt/strings.json @@ -214,15 +214,19 @@ "description": "Please configure specific details for {platform} entity \"{entity}\":", "data": { "device_class": "Device class", + "options": "Add option", + "schema": "Schema", "state_class": "State class", - "unit_of_measurement": "Unit of measurement", - "options": "Add option" + "suggested_display_precision": "Suggested display precision", + "unit_of_measurement": "Unit of measurement" }, "data_description": { "device_class": "The Device class of the {platform} entity. [Learn more.]({url}#device_class)", + "options": "Options for allowed sensor state values. The sensor’s Device class must be set to Enumeration. The 'Options' setting cannot be used together with State class or Unit of measurement.", + "schema": "The schema to use. [Learn more.]({url}#comparison-of-light-mqtt-schemas)", "state_class": "The [State class](https://developers.home-assistant.io/docs/core/entity/sensor/#available-state-classes) of the sensor. [Learn more.]({url}#state_class)", - "unit_of_measurement": "Defines the unit of measurement of the sensor, if any.", - "options": "Options for allowed sensor state values. The sensor’s Device class must be set to Enumeration. The 'Options' setting cannot be used together with State class or Unit of measurement." + "suggested_display_precision": "The number of decimals which should be used in the {platform} entity state after rounding. [Learn more.]({url}#suggested_display_precision)", + "unit_of_measurement": "Defines the unit of measurement of the sensor, if any." }, "sections": { "advanced_settings": { @@ -240,33 +244,222 @@ "title": "Configure MQTT device \"{mqtt_device}\"", "description": "Please configure MQTT specific details for {platform} entity \"{entity}\":", "data": { - "command_topic": "Command topic", + "on_command_type": "ON command type", + "blue_template": "Blue template", + "brightness_template": "Brightness template", "command_template": "Command template", - "state_topic": "State topic", - "value_template": "Value template", - "last_reset_value_template": "Last reset value template", + "command_topic": "Command topic", + "command_off_template": "Command \"off\" template", + "command_on_template": "Command \"on\" template", + "color_temp_template": "Color temperature template", "force_update": "Force update", + "green_template": "Green template", + "last_reset_value_template": "Last reset value template", "optimistic": "Optimistic", - "retain": "Retain" + "payload_off": "Payload off", + "payload_on": "Payload on", + "qos": "QoS", + "red_template": "Red template", + "retain": "Retain", + "state_template": "State template", + "state_topic": "State topic", + "state_value_template": "State value template", + "supported_color_modes": "Supported color modes", + "value_template": "Value template" }, "data_description": { - "command_topic": "The publishing topic that will be used to control the {platform} entity. [Learn more.]({url}#command_topic)", + "blue_template": "[Template](https://www.home-assistant.io/docs/configuration/templating/#using-value-templates-with-mqtt) to extract blue color from the state payload value. Expected result of the template is an integer from 0-255 range.", + "brightness_template": "[Template](https://www.home-assistant.io/docs/configuration/templating/#using-value-templates-with-mqtt) to extract brightness from the state payload value. Expected result of the template is an integer from 0-255 range.", + "command_off_template": "The [template](https://www.home-assistant.io/docs/configuration/templating/#using-command-templates-with-mqtt) for \"off\" state changes. Available variables are: `state` and `transition`.", + "command_on_template": "The [template](https://www.home-assistant.io/docs/configuration/templating/#using-command-templates-with-mqtt) for \"on\" state changes. Available variables: `state`, `brightness`, `color_temp`, `red`, `green`, `blue`, `hue`, `sat`, `flash`, `transition` and `effect`. Values `red`, `green`, `blue` and `brightness` are provided as integers from range 0-255. Value of `hue` is provided as float from range 0-360. Value of `sat` is provided as float from range 0-100. Value of `color_temp` is provided as integer representing Kelvin units.", "command_template": "A [template](https://www.home-assistant.io/docs/configuration/templating/#using-command-templates-with-mqtt) to render the payload to be published at the command topic.", - "state_topic": "The MQTT topic subscribed to receive {platform} state values. [Learn more.]({url}#state_topic)", - "value_template": "Defines a [template](https://www.home-assistant.io/docs/configuration/templating/#using-value-templates-with-mqtt) to extract the {platform} entity value.", + "command_topic": "The publishing topic that will be used to control the {platform} entity. [Learn more.]({url}#command_topic)", + "color_temp_template": "[Template](https://www.home-assistant.io/docs/configuration/templating/#using-value-templates-with-mqtt) to extract color temperature in Kelvin from the state payload value. Expected result of the template is an integer.", + "green_template": "[Template](https://www.home-assistant.io/docs/configuration/templating/#using-value-templates-with-mqtt) to extract green color from the state payload value. Expected result of the template is an integer from 0-255 range.", "last_reset_value_template": "Defines a [template](https://www.home-assistant.io/docs/configuration/templating/#using-value-templates-with-mqtt) to extract the last reset. When Last reset template is set, the State class option must be Total. [Learn more.]({url}#last_reset_value_template)", "force_update": "Sends update events even if the value hasn’t changed. Useful if you want to have meaningful value graphs in history. [Learn more.]({url}#force_update)", + "on_command_type": "Defines when the `payload on` is sent. Using `last` (the default) will send any style (brightness, color, etc) topics first and then a `payload on` to the command_topic. Using `first` will send the `payload on` and then any style topics. Using `brightness` will only send brightness commands instead of the `Payload on` to turn the light on.", "optimistic": "Flag that defines if the {platform} entity works in optimistic mode. [Learn more.]({url}#optimistic)", - "retain": "Select if values published by the {platform} entity should be retained at the MQTT broker." + "payload_off": "The payload that represents the off state.", + "payload_on": "The payload that represents the on state.", + "qos": "The QoS value a {platform} entity should use.", + "red_template": "[Template](https://www.home-assistant.io/docs/configuration/templating/#using-value-templates-with-mqtt) to extract red color from the state payload value. Expected result of the template is an integer from 0-255 range.", + "retain": "Select if values published by the {platform} entity should be retained at the MQTT broker.", + "state_template": "[Template](https://www.home-assistant.io/docs/configuration/templating/#using-value-templates-with-mqtt) to extract state from the state payload value.", + "state_topic": "The MQTT topic subscribed to receive {platform} state values. [Learn more.]({url}#state_topic)", + "supported_color_modes": "A list of color modes supported by the list. Possible color modes are On/Off, Brightness, Color temperature, HS, XY, RGB, RGBW, RGBWW, WHITE. Note that if onoff or brightness are used, that must be the only value in the list. [Learn more.]({url}#supported_color_modes)", + "value_template": "Defines a [template](https://www.home-assistant.io/docs/configuration/templating/#using-value-templates-with-mqtt) to extract the {platform} entity value. [Learn more.]({url}#value_template)" }, "sections": { "advanced_settings": { "name": "Advanced settings", "data": { - "expire_after": "Expire after" + "expire_after": "Expire after", + "flash": "Flash support", + "flash_time_long": "Flash time long", + "flash_time_short": "Flash time short", + "max_kelvin": "Max Kelvin", + "min_kelvin": "Min Kelvin", + "transition": "Transition support" + }, + "data_description": { + "expire_after": "If set, it defines the number of seconds after the sensor’s state expires, if it’s not updated. After expiry, the sensor’s state becomes unavailable. If not set, the sensor's state never expires. [Learn more.]({url}#expire_after)", + "flash": "Enable the flash feature for this light", + "flash_time_long": "The duration, in seconds, of a \"long\" flash.", + "flash_time_short": "The duration, in seconds, of a \"short\" flash.", + "max_kelvin": "The maximum color temperature in Kelvin.", + "min_kelvin": "The minimum color temperature in Kelvin.", + "transition": "Enable the transition feature for this light" + } + }, + "light_brightness_settings": { + "name": "Brightness settings", + "data": { + "brightness": "Separate brightness", + "brightness_command_template": "Brightness command template", + "brightness_command_topic": "Brightness command topic", + "brightness_scale": "Brightness scale", + "brightness_state_topic": "Brightness state topic", + "brightness_value_template": "Brightness value template" + }, + "data_description": { + "brightness": "Flag that defines if light supports brightness when the RGB, RGBW, or RGBWW color mode is supported.", + "brightness_command_template": "A [template](https://www.home-assistant.io/docs/configuration/templating/#using-command-templates-with-mqtt) to compose the payload to be published at the brightness command topic.", + "brightness_command_topic": "The publishing topic that will be used to control the brigthness. [Learn more.]({url}#brightness_command_topic)", + "brightness_scale": "Defines the maximum brightness value (i.e., 100%) of the maximum brightness.", + "brightness_state_topic": "The MQTT topic subscribed to receive brightness state values. [Learn more.]({url}#brightness_state_topic)", + "brightness_value_template": "Defines a [template](https://www.home-assistant.io/docs/configuration/templating/#using-value-templates-with-mqtt) to extract the brightness value." + } + }, + "light_color_mode_settings": { + "name": "Color mode settings", + "data": { + "color_mode_state_topic": "Color mode state topic", + "color_mode_value_template": "Color mode value template" + }, + "data_description": { + "color_mode_state_topic": "The MQTT topic subscribed to receive color mode updates. If this is not configured, the color mode will be automatically set according to the last received valid color or color temperature.", + "color_mode_value_template": "Defines a [template](https://www.home-assistant.io/docs/configuration/templating/#using-value-templates-with-mqtt) to extract the color mode value." + } + }, + "light_color_temp_settings": { + "name": "Color temperature settings", + "data": { + "color_temp_command_template": "Color temperature command template", + "color_temp_command_topic": "Color temperature command topic", + "color_temp_state_topic": "Color temperature state topic", + "color_temp_value_template": "Color temperature value template" + }, + "data_description": { + "color_temp_command_template": "A [template](https://www.home-assistant.io/docs/configuration/templating/#using-command-templates-with-mqtt) to compose the payload to be published at the color temperature command topic.", + "color_temp_command_topic": "The publishing topic that will be used to control the color temperature. [Learn more.]({url}#color_temp_command_topic)", + "color_temp_state_topic": "The MQTT topic subscribed to receive color temperature state updates. [Learn more.]({url}#color_temp_state_topic)", + "color_temp_value_template": "Defines a [template](https://www.home-assistant.io/docs/configuration/templating/#using-value-templates-with-mqtt) to extract the color temperature value." + } + }, + "light_effect_settings": { + "name": "Effect settings", + "data": { + "effect": "Effect", + "effect_command_template": "Effect command template", + "effect_command_topic": "Effect command topic", + "effect_list": "Effect list", + "effect_state_topic": "Effect state topic", + "effect_template": "Effect template", + "effect_value_template": "Effect value template" + }, + "data_description": { + "effect": "Flag that defines if the light supports effects.", + "effect_command_template": "A [template](https://www.home-assistant.io/docs/configuration/templating/#using-command-templates-with-mqtt) to compose the payload to be published at the effect command topic.", + "effect_command_topic": "The publishing topic that will be used to control the light's effect state. [Learn more.]({url}#effect_command_topic)", + "effect_list": "The list of effects the light supports.", + "effect_state_topic": "The MQTT topic subscribed to receive effect state updates. [Learn more.]({url}#effect_state_topic)" + } + }, + "light_hs_settings": { + "name": "HS color mode settings", + "data": { + "hs_command_template": "HS command template", + "hs_command_topic": "HS command topic", + "hs_state_topic": "HS state topic", + "hs_value_template": "HS value template" }, "data_description": { - "expire_after": "If set, it defines the number of seconds after the sensor’s state expires, if it’s not updated. After expiry, the sensor’s state becomes unavailable. If not set, the sensor's state never expires. [Learn more.]({url}#expire_after)" + "hs_command_template": "Defines a [template](https://www.home-assistant.io/docs/configuration/templating/#using-command-templates-with-mqtt) to compose message which will be sent to hs_command_topic. Available variables: `hue` and `sat`.", + "hs_command_topic": "The MQTT topic to publish commands to change the light’s color state in HS format (Hue Saturation). Range for Hue: 0° .. 360°, Range of Saturation: 0..100. Note: Brightness is sent separately in the brightness command topic. [Learn more.]({url}#hs_command_topic)", + "hs_state_topic": "The MQTT topic subscribed to receive color state updates in HS format. The expected payload is the hue and saturation values separated by commas, for example, `359.5,100.0`. Note: Brightness is received separately in the brightness state topic. [Learn more.]({url}#hs_state_topic)", + "hs_value_template": "Defines a [template](https://www.home-assistant.io/docs/configuration/templating/#using-value-templates-with-mqtt) to extract the HS value." + } + }, + "light_rgb_settings": { + "name": "RGB color mode settings", + "data": { + "rgb_command_template": "RGB command template", + "rgb_command_topic": "RGB command topic", + "rgb_state_topic": "RGB state topic", + "rgb_value_template": "RGB value template" + }, + "data_description": { + "rgb_command_template": "Defines a [template](https://www.home-assistant.io/docs/configuration/templating/#using-command-templates-with-mqtt) to compose message which will be sent to RGB command topic. Available variables: `red`, `green` and `blue`.", + "rgb_command_topic": "The MQTT topic to publish commands to change the light’s RGB state. [Learn more.]({url}#rgb_command_topic)", + "rgb_state_topic": "The MQTT topic subscribed to receive RGB state updates. The expected payload is the RGB values separated by commas, for example, `255,0,127`. [Learn more.]({url}rgb_state_topic)", + "rgb_value_template": "Defines a [template](https://www.home-assistant.io/docs/configuration/templating/#using-value-templates-with-mqtt) to extract the RGB value." + } + }, + "light_rgbw_settings": { + "name": "RGBW color mode settings", + "data": { + "rgbw_command_template": "RGBW command template", + "rgbw_command_topic": "RGBW command topic", + "rgbw_state_topic": "RGBW state topic", + "rgbw_value_template": "RGBW value template" + }, + "data_description": { + "rgbw_command_template": "Defines a [template](https://www.home-assistant.io/docs/configuration/templating/#using-command-templates-with-mqtt) to compose message which will be sent to RGBW command topic. Available variables: `red`, `green`, `blue` and `white`.", + "rgbw_command_topic": "The MQTT topic to publish commands to change the light’s RGBW state. [Learn more.]({url}#rgbw_command_topic)", + "rgbw_state_topic": "The MQTT topic subscribed to receive RGBW state updates. The expected payload is the RGBW values separated by commas, for example, `255,0,127,64`. [Learn more.]({url}#rgbw_state_topic)", + "rgbw_value_template": "Defines a [template](https://www.home-assistant.io/docs/configuration/templating/#using-value-templates-with-mqtt) to extract the RGBW value." + } + }, + "light_rgbww_settings": { + "name": "RGBWW color mode settings", + "data": { + "rgbww_command_template": "RGBWW command template", + "rgbww_command_topic": "RGBWW command topic", + "rgbww_state_topic": "RGBWW state topic", + "rgbww_value_template": "RGBWW value template" + }, + "data_description": { + "rgbww_command_template": "Defines a [template](https://www.home-assistant.io/docs/configuration/templating/#using-command-templates-with-mqtt) to compose message which will be sent to RGBWW command topic. Available variables: `red`, `green`, `blue`, `cold_white` and `warm_white`.", + "rgbww_command_topic": "The MQTT topic to publish commands to change the light’s RGBWW state. [Learn more.]({url}#rgbww_command_topic)", + "rgbww_state_topic": "The MQTT topic subscribed to receive RGBWW state updates. The expected payload is the RGBWW values separated by commas, for example, `255,0,127,64,32`. [Learn more.]({url}#rgbww_state_topic)", + "rgbww_value_template": "Defines a [template](https://www.home-assistant.io/docs/configuration/templating/#using-value-templates-with-mqtt) to extract the RGBWW value." + } + }, + "light_white_settings": { + "name": "White color mode settings", + "data": { + "white_command_topic": "White command topic", + "white_scale": "White scale" + }, + "data_description": { + "white_command_topic": "The MQTT topic to publish commands to change the light to white mode with a given brightness. [Learn more.]({url}#white_command_topic)", + "white_scale": "Defines the maximum white level (i.e., 100%) of the maximum." + } + }, + "light_xy_settings": { + "name": "XY color mode settings", + "data": { + "xy_command_template": "XY command template", + "xy_command_topic": "XY command topic", + "xy_state_topic": "XY state topic", + "xy_value_template": "XY value template" + }, + "data_description": { + "xy_command_template": "Defines a [template](https://www.home-assistant.io/docs/configuration/templating/#using-command-templates-with-mqtt) to compose message which will be sent to XY command topic. Available variables: `x` and `y`.", + "xy_command_topic": "The MQTT topic to publish commands to change the light’s XY state. [Learn more.]({url}#xy_command_topic)", + "xy_state_topic": "The MQTT topic subscribed to receive XY state updates. The expected payload is the X and Y color values separated by commas, for example, `0.675,0.322`. [Learn more.]({url}#xy_state_topic)", + "xy_value_template": "Defines a [template](https://www.home-assistant.io/docs/configuration/templating/#using-value-templates-with-mqtt) to extract the XY value." } } } @@ -282,8 +475,11 @@ "invalid_input": "Invalid value", "invalid_subscribe_topic": "Invalid subscribe topic", "invalid_template": "Invalid template", + "invalid_supported_color_modes": "Invalid supported color modes selection", "invalid_uom": "The unit of measurement \"{unit_of_measurement}\" is not supported by the selected device class, please either remove the device class, select a device class which supports \"{unit_of_measurement}\", or pick a supported unit of measurement from the list", "invalid_url": "Invalid URL", + "last_reset_not_with_state_class_total": "The last reset value template option should be used with state class 'Total' only", + "max_below_min_kelvin": "Max Kelvin value should be greater than min Kelvin value", "options_not_allowed_with_state_class_or_uom": "The 'Options' setting is not allowed when state class or unit of measurement are used", "options_device_class_enum": "The 'Options' setting must be used with the Enumeration device class. If you continue, the existing options will be reset", "options_with_enum_device_class": "Configure options for the enumeration sensor", @@ -470,8 +666,23 @@ "switch": "[%key:component::switch::title%]" } }, + "light_schema": { + "options": { + "basic": "Default schema", + "json": "JSON", + "template": "Template" + } + }, + "on_command_type": { + "options": { + "brightness": "Brightness", + "first": "First", + "last": "Last" + } + }, "platform": { "options": { + "light": "[%key:component::light::title%]", "notify": "[%key:component::notify::title%]", "sensor": "[%key:component::sensor::title%]", "switch": "[%key:component::switch::title%]" @@ -490,6 +701,19 @@ "total": "[%key:component::sensor::entity_component::_::state_attributes::state_class::state::total%]", "total_increasing": "[%key:component::sensor::entity_component::_::state_attributes::state_class::state::total_increasing%]" } + }, + "supported_color_modes": { + "options": { + "onoff": "[%key:component::light::entity_component::_::state_attributes::color_mode::state::onoff%]", + "brightness": "[%key:component::light::entity_component::_::state_attributes::color_mode::state::brightness%]", + "color_temp": "[%key:component::light::entity_component::_::state_attributes::color_mode::state::color_temp%]", + "hs": "[%key:component::light::entity_component::_::state_attributes::color_mode::state::hs%]", + "xy": "[%key:component::light::entity_component::_::state_attributes::color_mode::state::xy%]", + "rgb": "[%key:component::light::entity_component::_::state_attributes::color_mode::state::rgb%]", + "rgbw": "[%key:component::light::entity_component::_::state_attributes::color_mode::state::rgbw%]", + "rgbww": "[%key:component::light::entity_component::_::state_attributes::color_mode::state::rgbww%]", + "white": "[%key:component::light::entity_component::_::state_attributes::color_mode::state::white%]" + } } }, "services": { diff --git a/tests/components/mqtt/common.py b/tests/components/mqtt/common.py index e4a368f0d71c87..d811b601036763 100644 --- a/tests/components/mqtt/common.py +++ b/tests/components/mqtt/common.py @@ -139,15 +139,19 @@ }, } -# Bogus light component just for code coverage -# Note that light cannot be setup through the UI yet -# The test is for code coverage -MOCK_SUBENTRY_LIGHT_COMPONENT = { +MOCK_SUBENTRY_LIGHT_BASIC_KELVIN_COMPONENT = { "8131babc5e8d4f44b82e0761d39091a2": { "platform": "light", - "name": "Test light", - "command_topic": "test-topic4", + "name": "Basic light", + "on_command_type": "last", + "optimistic": True, + "payload_off": "OFF", + "payload_on": "ON", + "command_topic": "test-topic", "schema": "basic", + "state_topic": "test-topic", + "color_temp_kelvin": True, + "state_value_template": "{{ value_json.value }}", "entity_picture": "https://example.com/8131babc5e8d4f44b82e0761d39091a2", }, } @@ -168,108 +172,57 @@ } } +MOCK_SUBENTRY_DEVICE_DATA = { + "name": "Milk notifier", + "sw_version": "1.0", + "hw_version": "2.1 rev a", + "model": "Model XL", + "model_id": "mn002", + "configuration_url": "https://example.com", +} + MOCK_NOTIFY_SUBENTRY_DATA_MULTI = { - "device": { - "name": "Milk notifier", - "sw_version": "1.0", - "hw_version": "2.1 rev a", - "model": "Model XL", - "model_id": "mn002", - "configuration_url": "https://example.com", - }, + "device": MOCK_SUBENTRY_DEVICE_DATA | {"mqtt_settings": {"qos": 0}}, "components": MOCK_SUBENTRY_NOTIFY_COMPONENT1 | MOCK_SUBENTRY_NOTIFY_COMPONENT2, } | MOCK_SUBENTRY_AVAILABILITY_DATA MOCK_NOTIFY_SUBENTRY_DATA_SINGLE = { - "device": { - "name": "Milk notifier", - "sw_version": "1.0", - "hw_version": "2.1 rev a", - "model": "Model XL", - "model_id": "mn002", - "configuration_url": "https://example.com", - "mqtt_settings": {"qos": 1}, - }, + "device": MOCK_SUBENTRY_DEVICE_DATA | {"mqtt_settings": {"qos": 1}}, "components": MOCK_SUBENTRY_NOTIFY_COMPONENT1, } MOCK_NOTIFY_SUBENTRY_DATA_NO_NAME = { - "device": { - "name": "Milk notifier", - "sw_version": "1.0", - "hw_version": "2.1 rev a", - "model": "Model XL", - "model_id": "mn002", - "configuration_url": "https://example.com", - }, + "device": MOCK_SUBENTRY_DEVICE_DATA | {"mqtt_settings": {"qos": 0}}, "components": MOCK_SUBENTRY_NOTIFY_COMPONENT_NO_NAME, } +MOCK_LIGHT_BASIC_KELVIN_SUBENTRY_DATA_SINGLE = { + "device": MOCK_SUBENTRY_DEVICE_DATA | {"mqtt_settings": {"qos": 0}}, + "components": MOCK_SUBENTRY_LIGHT_BASIC_KELVIN_COMPONENT, +} MOCK_SENSOR_SUBENTRY_DATA_SINGLE = { - "device": { - "name": "Test sensor", - "sw_version": "1.0", - "hw_version": "2.1 rev a", - "model": "Model XL", - "model_id": "mn002", - "configuration_url": "https://example.com", - }, + "device": MOCK_SUBENTRY_DEVICE_DATA | {"mqtt_settings": {"qos": 0}}, "components": MOCK_SUBENTRY_SENSOR_COMPONENT, } MOCK_SENSOR_SUBENTRY_DATA_SINGLE_STATE_CLASS = { - "device": { - "name": "Test sensor", - "sw_version": "1.0", - "hw_version": "2.1 rev a", - "model": "Model XL", - "model_id": "mn002", - "configuration_url": "https://example.com", - }, + "device": MOCK_SUBENTRY_DEVICE_DATA | {"mqtt_settings": {"qos": 0}}, "components": MOCK_SUBENTRY_SENSOR_COMPONENT_STATE_CLASS, } MOCK_SENSOR_SUBENTRY_DATA_SINGLE_LAST_RESET_TEMPLATE = { - "device": { - "name": "Test sensor", - "sw_version": "1.0", - "hw_version": "2.1 rev a", - "model": "Model XL", - "model_id": "mn002", - "configuration_url": "https://example.com", - }, + "device": MOCK_SUBENTRY_DEVICE_DATA | {"mqtt_settings": {"qos": 0}}, "components": MOCK_SUBENTRY_SENSOR_COMPONENT_LAST_RESET, } MOCK_SWITCH_SUBENTRY_DATA_SINGLE_STATE_CLASS = { - "device": { - "name": "Test switch", - "sw_version": "1.0", - "hw_version": "2.1 rev a", - "model": "Model XL", - "model_id": "mn002", - "configuration_url": "https://example.com", - }, + "device": MOCK_SUBENTRY_DEVICE_DATA | {"mqtt_settings": {"qos": 0}}, "components": MOCK_SUBENTRY_SWITCH_COMPONENT, } MOCK_SUBENTRY_DATA_BAD_COMPONENT_SCHEMA = { - "device": { - "name": "Milk notifier", - "sw_version": "1.0", - "hw_version": "2.1 rev a", - "model": "Model XL", - "model_id": "mn002", - "configuration_url": "https://example.com", - }, + "device": MOCK_SUBENTRY_DEVICE_DATA | {"mqtt_settings": {"qos": 0}}, "components": MOCK_SUBENTRY_NOTIFY_BAD_SCHEMA, } MOCK_SUBENTRY_DATA_SET_MIX = { - "device": { - "name": "Milk notifier", - "sw_version": "1.0", - "hw_version": "2.1 rev a", - "model": "Model XL", - "model_id": "mn002", - "configuration_url": "https://example.com", - }, + "device": MOCK_SUBENTRY_DEVICE_DATA | {"mqtt_settings": {"qos": 0}}, "components": MOCK_SUBENTRY_NOTIFY_COMPONENT1 | MOCK_SUBENTRY_NOTIFY_COMPONENT2 - | MOCK_SUBENTRY_LIGHT_COMPONENT + | MOCK_SUBENTRY_LIGHT_BASIC_KELVIN_COMPONENT | MOCK_SUBENTRY_SWITCH_COMPONENT, } | MOCK_SUBENTRY_AVAILABILITY_DATA _SENTINEL = object() diff --git a/tests/components/mqtt/test_config_flow.py b/tests/components/mqtt/test_config_flow.py index 5824c9b886d6ca..b3d2769de6af34 100644 --- a/tests/components/mqtt/test_config_flow.py +++ b/tests/components/mqtt/test_config_flow.py @@ -33,6 +33,7 @@ from homeassistant.helpers.service_info.hassio import HassioServiceInfo from .common import ( + MOCK_LIGHT_BASIC_KELVIN_SUBENTRY_DATA_SINGLE, MOCK_NOTIFY_SUBENTRY_DATA_MULTI, MOCK_NOTIFY_SUBENTRY_DATA_NO_NAME, MOCK_NOTIFY_SUBENTRY_DATA_SINGLE, @@ -2696,7 +2697,7 @@ async def test_migrate_of_incompatible_config_entry( ), ( MOCK_SENSOR_SUBENTRY_DATA_SINGLE, - {"name": "Test sensor", "mqtt_settings": {"qos": 0}}, + {"name": "Milk notifier", "mqtt_settings": {"qos": 0}}, {"name": "Energy"}, {"device_class": "enum", "options": ["low", "medium", "high"]}, ( @@ -2748,11 +2749,11 @@ async def test_migrate_of_incompatible_config_entry( {"state_topic": "invalid_subscribe_topic"}, ), ), - "Test sensor Energy", + "Milk notifier Energy", ), ( MOCK_SENSOR_SUBENTRY_DATA_SINGLE_STATE_CLASS, - {"name": "Test sensor", "mqtt_settings": {"qos": 0}}, + {"name": "Milk notifier", "mqtt_settings": {"qos": 0}}, {"name": "Energy"}, { "state_class": "measurement", @@ -2762,11 +2763,11 @@ async def test_migrate_of_incompatible_config_entry( "state_topic": "test-topic", }, (), - "Test sensor Energy", + "Milk notifier Energy", ), ( MOCK_SWITCH_SUBENTRY_DATA_SINGLE_STATE_CLASS, - {"name": "Test switch", "mqtt_settings": {"qos": 0}}, + {"name": "Milk notifier", "mqtt_settings": {"qos": 0}}, {"name": "Outlet"}, {"device_class": "outlet"}, (), @@ -2790,7 +2791,44 @@ async def test_migrate_of_incompatible_config_entry( {"state_topic": "invalid_subscribe_topic"}, ), ), - "Test switch Outlet", + "Milk notifier Outlet", + ), + ( + MOCK_LIGHT_BASIC_KELVIN_SUBENTRY_DATA_SINGLE, + {"name": "Milk notifier", "mqtt_settings": {"qos": 1}}, + {"name": "Basic light"}, + {}, + {}, + { + "command_topic": "test-topic", + "state_topic": "test-topic", + "state_value_template": "{{ value_json.value }}", + "optimistic": True, + }, + ( + ( + {"command_topic": "test-topic#invalid"}, + {"command_topic": "invalid_publish_topic"}, + ), + ( + { + "command_topic": "test-topic", + "state_topic": "test-topic#invalid", + }, + {"state_topic": "invalid_subscribe_topic"}, + ), + ( + { + "command_topic": "test-topic", + "advanced_settings": {"max_kelvin": 2000, "min_kelvin": 2000}, + }, + { + "max_kelvin": "max_below_min_kelvin", + "min_kelvin": "max_below_min_kelvin", + }, + ), + ), + "Milk notifier Basic light", ), ], ids=[ @@ -2799,6 +2837,7 @@ async def test_migrate_of_incompatible_config_entry( "sensor_options", "sensor_total", "switch", + "light_basic_kelvin", ], ) async def test_subentry_configflow( @@ -3199,6 +3238,7 @@ async def test_subentry_reconfigure_edit_entity_multi_entitites( "user_input_platform_config_validation", "user_input_platform_config", "user_input_mqtt", + "component_data", "removed_options", ), [ @@ -3217,6 +3257,11 @@ async def test_subentry_reconfigure_edit_entity_multi_entitites( "command_template": "{{ value }}", "retain": True, }, + { + "command_topic": "test-topic1-updated", + "command_template": "{{ value }}", + "retain": True, + }, {"entity_picture"}, ), ( @@ -3253,10 +3298,38 @@ async def test_subentry_reconfigure_edit_entity_multi_entitites( "state_topic": "test-topic1-updated", "value_template": "{{ value_json.value }}", }, + { + "state_topic": "test-topic1-updated", + "value_template": "{{ value_json.value }}", + }, {"options", "expire_after", "entity_picture"}, ), + ( + ( + ConfigSubentryData( + data=MOCK_LIGHT_BASIC_KELVIN_SUBENTRY_DATA_SINGLE, + subentry_type="device", + title="Mock subentry", + ), + ), + None, + None, + { + "command_topic": "test-topic1-updated", + "state_topic": "test-topic1-updated", + "light_brightness_settings": { + "brightness_command_template": "{{ value_json.value }}" + }, + }, + { + "command_topic": "test-topic1-updated", + "state_topic": "test-topic1-updated", + "brightness_command_template": "{{ value_json.value }}", + }, + {"optimistic", "state_value_template", "entity_picture"}, + ), ], - ids=["notify", "sensor"], + ids=["notify", "sensor", "light_basic"], ) async def test_subentry_reconfigure_edit_entity_single_entity( hass: HomeAssistant, @@ -3269,6 +3342,7 @@ async def test_subentry_reconfigure_edit_entity_single_entity( | None, user_input_platform_config: dict[str, Any] | None, user_input_mqtt: dict[str, Any], + component_data: dict[str, Any], removed_options: tuple[str, ...], ) -> None: """Test the subentry ConfigFlow reconfigure with single entity.""" @@ -3373,7 +3447,7 @@ async def test_subentry_reconfigure_edit_entity_single_entity( assert "entity_picture" not in new_components[component_id] # Check the second component was updated - for key, value in user_input_mqtt.items(): + for key, value in component_data.items(): assert new_components[component_id][key] == value assert set(component) - set(new_components[component_id]) == removed_options From 80e4f191720a2a50c7fc8075477fc3576a1bd513 Mon Sep 17 00:00:00 2001 From: Martin Hjelmare Date: Wed, 30 Apr 2025 16:14:44 +0200 Subject: [PATCH 32/37] Fix Z-Wave USB flow test warning (#143956) --- tests/components/zwave_js/test_config_flow.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/tests/components/zwave_js/test_config_flow.py b/tests/components/zwave_js/test_config_flow.py index 8256e10e6975b7..1d8b997ea4d584 100644 --- a/tests/components/zwave_js/test_config_flow.py +++ b/tests/components/zwave_js/test_config_flow.py @@ -1210,7 +1210,7 @@ async def test_abort_usb_discovery_with_existing_flow( assert result2["reason"] == "already_in_progress" -@pytest.mark.usefixtures("supervisor", "addon_options") +@pytest.mark.usefixtures("supervisor", "addon_installed") async def test_usb_discovery_with_existing_usb_flow(hass: HomeAssistant) -> None: """Test usb discovery allows more than one USB flow in progress.""" first_usb_info = UsbServiceInfo( @@ -1244,6 +1244,11 @@ async def test_usb_discovery_with_existing_usb_flow(hass: HomeAssistant) -> None assert len(usb_flows_in_progress) == 2 + for flow in (result, result2): + hass.config_entries.flow.async_abort(flow["flow_id"]) + + assert len(hass.config_entries.flow.async_progress()) == 0 + async def test_abort_usb_discovery_addon_required( hass: HomeAssistant, supervisor, addon_options From 819be719ef2c403a48d478c95c6a2b7160c032ee Mon Sep 17 00:00:00 2001 From: Robert Resch Date: Wed, 30 Apr 2025 16:16:55 +0200 Subject: [PATCH 33/37] Bump uv to 0.7.1 (#143957) * Bump uv to 0.6.17 * Bump uv to 0.7.1 --- Dockerfile | 2 +- homeassistant/package_constraints.txt | 2 +- pyproject.toml | 2 +- requirements.txt | 2 +- script/hassfest/docker/Dockerfile | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/Dockerfile b/Dockerfile index 0a74e0a3aac346..549837ddef054f 100644 --- a/Dockerfile +++ b/Dockerfile @@ -31,7 +31,7 @@ RUN \ && go2rtc --version # Install uv -RUN pip3 install uv==0.6.10 +RUN pip3 install uv==0.7.1 WORKDIR /usr/src diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index ce943f2b712333..6b3f3521be02da 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -69,7 +69,7 @@ standard-telnetlib==3.13.0 typing-extensions>=4.13.0,<5.0 ulid-transform==1.4.0 urllib3>=1.26.5,<2 -uv==0.6.10 +uv==0.7.1 voluptuous-openapi==0.0.7 voluptuous-serialize==2.6.0 voluptuous==0.15.2 diff --git a/pyproject.toml b/pyproject.toml index 9315e2c7e8995e..98d3c065f5d1ea 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -117,7 +117,7 @@ dependencies = [ # Temporary setting an upper bound, to prevent compat issues with urllib3>=2 # https://github.com/home-assistant/core/issues/97248 "urllib3>=1.26.5,<2", - "uv==0.6.10", + "uv==0.7.1", "voluptuous==0.15.2", "voluptuous-serialize==2.6.0", "voluptuous-openapi==0.0.7", diff --git a/requirements.txt b/requirements.txt index 45af8b647de09a..0cd0bda1d2bf62 100644 --- a/requirements.txt +++ b/requirements.txt @@ -54,7 +54,7 @@ standard-telnetlib==3.13.0 typing-extensions>=4.13.0,<5.0 ulid-transform==1.4.0 urllib3>=1.26.5,<2 -uv==0.6.10 +uv==0.7.1 voluptuous==0.15.2 voluptuous-serialize==2.6.0 voluptuous-openapi==0.0.7 diff --git a/script/hassfest/docker/Dockerfile b/script/hassfest/docker/Dockerfile index bfdb61096b656f..e434b72ce5c1c8 100644 --- a/script/hassfest/docker/Dockerfile +++ b/script/hassfest/docker/Dockerfile @@ -14,7 +14,7 @@ WORKDIR "/github/workspace" COPY . /usr/src/homeassistant # Uv is only needed during build -RUN --mount=from=ghcr.io/astral-sh/uv:0.6.10,source=/uv,target=/bin/uv \ +RUN --mount=from=ghcr.io/astral-sh/uv:0.7.1,source=/uv,target=/bin/uv \ # Uv creates a lock file in /tmp --mount=type=tmpfs,target=/tmp \ # Required for PyTurboJPEG From 4061314cd271929b25442d4576ebbe07cea74d71 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=2E=20Diego=20Rodr=C3=ADguez=20Royo?= Date: Wed, 30 Apr 2025 16:22:18 +0200 Subject: [PATCH 34/37] Allow multiple config entries in Home Connect (#143935) * Allow multiple config entries in Home Connect * Config entry migration * Create new entry if reauth flow is completed with other account * Abort if different account on reauth --- .../components/home_connect/__init__.py | 53 +++++--- .../components/home_connect/config_flow.py | 13 +- .../components/home_connect/manifest.json | 1 - .../components/home_connect/strings.json | 4 +- tests/components/home_connect/conftest.py | 22 +++- .../home_connect/test_config_flow.py | 116 ++++++++++++++++-- tests/components/home_connect/test_init.py | 17 +++ 7 files changed, 190 insertions(+), 36 deletions(-) diff --git a/homeassistant/components/home_connect/__init__.py b/homeassistant/components/home_connect/__init__.py index 38db34aa72a40d..01f2acd1851423 100644 --- a/homeassistant/components/home_connect/__init__.py +++ b/homeassistant/components/home_connect/__init__.py @@ -7,6 +7,7 @@ from aiohomeconnect.client import Client as HomeConnectClient import aiohttp +import jwt from homeassistant.const import Platform from homeassistant.core import HomeAssistant, callback @@ -110,25 +111,39 @@ async def async_migrate_entry( """Migrate old entry.""" _LOGGER.debug("Migrating from version %s", entry.version) - if entry.version == 1 and entry.minor_version == 1: - - @callback - def update_unique_id( - entity_entry: RegistryEntry, - ) -> dict[str, Any] | None: - """Update unique ID of entity entry.""" - for old_id_suffix, new_id_suffix in OLD_NEW_UNIQUE_ID_SUFFIX_MAP.items(): - if entity_entry.unique_id.endswith(f"-{old_id_suffix}"): - return { - "new_unique_id": entity_entry.unique_id.replace( - old_id_suffix, new_id_suffix - ) - } - return None - - await async_migrate_entries(hass, entry.entry_id, update_unique_id) - - hass.config_entries.async_update_entry(entry, minor_version=2) + if entry.version == 1: + match entry.minor_version: + case 1: + + @callback + def update_unique_id( + entity_entry: RegistryEntry, + ) -> dict[str, Any] | None: + """Update unique ID of entity entry.""" + for ( + old_id_suffix, + new_id_suffix, + ) in OLD_NEW_UNIQUE_ID_SUFFIX_MAP.items(): + if entity_entry.unique_id.endswith(f"-{old_id_suffix}"): + return { + "new_unique_id": entity_entry.unique_id.replace( + old_id_suffix, new_id_suffix + ) + } + return None + + await async_migrate_entries(hass, entry.entry_id, update_unique_id) + + hass.config_entries.async_update_entry(entry, minor_version=2) + case 2: + hass.config_entries.async_update_entry( + entry, + minor_version=3, + unique_id=jwt.decode( + entry.data["token"]["access_token"], + options={"verify_signature": False}, + )["sub"], + ) _LOGGER.debug("Migration to version %s successful", entry.version) return True diff --git a/homeassistant/components/home_connect/config_flow.py b/homeassistant/components/home_connect/config_flow.py index 02a3ca29335870..2b3b2aacf0c2ae 100644 --- a/homeassistant/components/home_connect/config_flow.py +++ b/homeassistant/components/home_connect/config_flow.py @@ -4,6 +4,7 @@ import logging from typing import Any +import jwt import voluptuous as vol from homeassistant.config_entries import SOURCE_REAUTH, ConfigFlowResult @@ -19,7 +20,7 @@ class OAuth2FlowHandler( DOMAIN = DOMAIN - MINOR_VERSION = 2 + MINOR_VERSION = 3 @property def logger(self) -> logging.Logger: @@ -45,9 +46,15 @@ async def async_step_reauth_confirm( async def async_oauth_create_entry(self, data: dict) -> ConfigFlowResult: """Create an oauth config entry or update existing entry for reauth.""" + await self.async_set_unique_id( + jwt.decode( + data["token"]["access_token"], options={"verify_signature": False} + )["sub"] + ) if self.source == SOURCE_REAUTH: + self._abort_if_unique_id_mismatch(reason="wrong_account") return self.async_update_reload_and_abort( - self._get_reauth_entry(), - data_updates=data, + self._get_reauth_entry(), data_updates=data ) + self._abort_if_unique_id_configured() return await super().async_oauth_create_entry(data) diff --git a/homeassistant/components/home_connect/manifest.json b/homeassistant/components/home_connect/manifest.json index 29fd4bfb3fe8e2..8a608a900be5e8 100644 --- a/homeassistant/components/home_connect/manifest.json +++ b/homeassistant/components/home_connect/manifest.json @@ -8,6 +8,5 @@ "iot_class": "cloud_push", "loggers": ["aiohomeconnect"], "requirements": ["aiohomeconnect==0.17.0"], - "single_config_entry": true, "zeroconf": ["_homeconnect._tcp.local."] } diff --git a/homeassistant/components/home_connect/strings.json b/homeassistant/components/home_connect/strings.json index d16459bc594619..ca79ec56ee40f6 100644 --- a/homeassistant/components/home_connect/strings.json +++ b/homeassistant/components/home_connect/strings.json @@ -14,13 +14,15 @@ } }, "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_account%]", "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", "missing_configuration": "[%key:common::config_flow::abort::oauth2_missing_configuration%]", "no_url_available": "[%key:common::config_flow::abort::oauth2_no_url_available%]", "oauth_error": "[%key:common::config_flow::abort::oauth2_error%]", "oauth_timeout": "[%key:common::config_flow::abort::oauth2_timeout%]", "oauth_unauthorized": "[%key:common::config_flow::abort::oauth2_unauthorized%]", - "oauth_failed": "[%key:common::config_flow::abort::oauth2_failed%]" + "oauth_failed": "[%key:common::config_flow::abort::oauth2_failed%]", + "wrong_account": "Please ensure you reconfigure against the same account." }, "create_entry": { "default": "[%key:common::config_flow::create_entry::authenticated%]" diff --git a/tests/components/home_connect/conftest.py b/tests/components/home_connect/conftest.py index 21cd236b1a8c2b..516701f2360106 100644 --- a/tests/components/home_connect/conftest.py +++ b/tests/components/home_connect/conftest.py @@ -46,7 +46,11 @@ CLIENT_ID = "1234" CLIENT_SECRET = "5678" -FAKE_ACCESS_TOKEN = "some-access-token" +FAKE_ACCESS_TOKEN = ( + "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9" + ".eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ" + ".SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c" +) FAKE_REFRESH_TOKEN = "some-refresh-token" FAKE_AUTH_IMPL = "conftest-imported-cred" @@ -84,7 +88,8 @@ def mock_config_entry(token_entry: dict[str, Any]) -> MockConfigEntry: "auth_implementation": FAKE_AUTH_IMPL, "token": token_entry, }, - minor_version=2, + minor_version=3, + unique_id="1234567890", ) @@ -101,6 +106,19 @@ def mock_config_entry_v1_1(token_entry: dict[str, Any]) -> MockConfigEntry: ) +@pytest.fixture(name="config_entry_v1_2") +def mock_config_entry_v1_2(token_entry: dict[str, Any]) -> MockConfigEntry: + """Fixture for a config entry.""" + return MockConfigEntry( + domain=DOMAIN, + data={ + "auth_implementation": FAKE_AUTH_IMPL, + "token": token_entry, + }, + minor_version=2, + ) + + @pytest.fixture async def setup_credentials(hass: HomeAssistant) -> None: """Fixture to setup credentials.""" diff --git a/tests/components/home_connect/test_config_flow.py b/tests/components/home_connect/test_config_flow.py index c35678e4e5fe88..19182a12194df3 100644 --- a/tests/components/home_connect/test_config_flow.py +++ b/tests/components/home_connect/test_config_flow.py @@ -13,10 +13,13 @@ async_import_client_credential, ) from homeassistant.components.home_connect.const import DOMAIN +from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType from homeassistant.helpers import config_entry_oauth2_flow +from .conftest import FAKE_ACCESS_TOKEN, FAKE_REFRESH_TOKEN + from tests.common import MockConfigEntry from tests.test_util.aiohttp import AiohttpClientMocker from tests.typing import ClientSessionGenerator @@ -64,8 +67,8 @@ async def test_full_flow( aioclient_mock.post( OAUTH2_TOKEN, json={ - "refresh_token": "mock-refresh-token", - "access_token": "mock-access-token", + "refresh_token": FAKE_REFRESH_TOKEN, + "access_token": FAKE_ACCESS_TOKEN, "type": "Bearer", "expires_in": 60, }, @@ -77,23 +80,64 @@ async def test_full_flow( result = await hass.config_entries.flow.async_configure(result["flow_id"]) await hass.async_block_till_done() - assert len(hass.config_entries.async_entries(DOMAIN)) == 1 + assert hass.config_entries.async_entry_for_domain_unique_id(DOMAIN, "1234567890") assert len(mock_setup_entry.mock_calls) == 1 -async def test_prevent_multiple_config_entries( +@pytest.mark.usefixtures("current_request_with_host") +async def test_prevent_reconfiguring_same_account( hass: HomeAssistant, + hass_client_no_auth: ClientSessionGenerator, + aioclient_mock: AiohttpClientMocker, config_entry: MockConfigEntry, ) -> None: - """Test we only allow one config entry.""" + """Test we only allow one config entry per account.""" config_entry.add_to_hass(hass) + assert await setup.async_setup_component(hass, "home_connect", {}) + + await async_import_client_credential( + hass, DOMAIN, ClientCredential(CLIENT_ID, CLIENT_SECRET) + ) + result = await hass.config_entries.flow.async_init( "home_connect", context={"source": config_entries.SOURCE_USER} ) + state = config_entry_oauth2_flow._encode_jwt( + hass, + { + "flow_id": result["flow_id"], + "redirect_uri": "https://example.com/auth/external/callback", + }, + ) + + assert result["type"] is FlowResultType.EXTERNAL_STEP + assert result["url"] == ( + f"{OAUTH2_AUTHORIZE}?response_type=code&client_id={CLIENT_ID}" + "&redirect_uri=https://example.com/auth/external/callback" + f"&state={state}" + ) + + client = await hass_client_no_auth() + resp = await client.get(f"/auth/external/callback?code=abcd&state={state}") + assert resp.status == HTTPStatus.OK + assert resp.headers["content-type"] == "text/html; charset=utf-8" + + aioclient_mock.post( + OAUTH2_TOKEN, + json={ + "refresh_token": FAKE_REFRESH_TOKEN, + "access_token": FAKE_ACCESS_TOKEN, + "type": "Bearer", + "expires_in": 60, + }, + ) + + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + await hass.async_block_till_done() assert result["type"] == "abort" - assert result["reason"] == "single_instance_allowed" + assert result["reason"] == "already_configured" @pytest.mark.usefixtures("current_request_with_host") @@ -129,8 +173,8 @@ async def test_reauth_flow( aioclient_mock.post( OAUTH2_TOKEN, json={ - "refresh_token": "mock-refresh-token", - "access_token": "mock-access-token", + "refresh_token": FAKE_REFRESH_TOKEN, + "access_token": FAKE_ACCESS_TOKEN, "type": "Bearer", "expires_in": 60, }, @@ -142,9 +186,61 @@ async def test_reauth_flow( result = await hass.config_entries.flow.async_configure(result["flow_id"]) await hass.async_block_till_done() - assert len(hass.config_entries.async_entries(DOMAIN)) == 1 + entry = hass.config_entries.async_entry_for_domain_unique_id(DOMAIN, "1234567890") + assert entry + assert entry.state is ConfigEntryState.LOADED assert len(mock_setup_entry.mock_calls) == 1 - await hass.async_block_till_done() assert result["type"] == FlowResultType.ABORT assert result["reason"] == "reauth_successful" + + +@pytest.mark.usefixtures("current_request_with_host") +async def test_reauth_flow_with_different_account( + hass: HomeAssistant, + config_entry: MockConfigEntry, + integration_setup: Callable[[MagicMock], Awaitable[bool]], + setup_credentials: None, + client: MagicMock, + hass_client_no_auth: ClientSessionGenerator, + aioclient_mock: AiohttpClientMocker, +) -> None: + """Test reauth flow.""" + result = await config_entry.start_reauth_flow(hass) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + state = config_entry_oauth2_flow._encode_jwt( + hass, + { + "flow_id": result["flow_id"], + "redirect_uri": "https://example.com/auth/external/callback", + }, + ) + + _client = await hass_client_no_auth() + resp = await _client.get(f"/auth/external/callback?code=abcd&state={state}") + assert resp.status == HTTPStatus.OK + assert resp.headers["content-type"] == "text/html; charset=utf-8" + + aioclient_mock.post( + OAUTH2_TOKEN, + json={ + "refresh_token": FAKE_REFRESH_TOKEN, + "access_token": ( + "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9" + ".eyJzdWIiOiJBQkNERSIsIm5hbWUiOiJKb2huIERvZSIsImFkbWluIjp0cnVlLCJpYXQiOjE1MTYyMzkwMjJ9" + ".Q9z9JT4qgNg9Y9ki61jzvd69j043GFWJk9HNYosAPzs" + ), + "type": "Bearer", + "expires_in": 60, + }, + ) + + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "wrong_account" diff --git a/tests/components/home_connect/test_init.py b/tests/components/home_connect/test_init.py index 21bb0291e1ad17..2147d9b170a818 100644 --- a/tests/components/home_connect/test_init.py +++ b/tests/components/home_connect/test_init.py @@ -358,3 +358,20 @@ async def test_bsh_key_transformations() -> None: program = "Dishcare.Dishwasher.Program.Eco50" translation_key = bsh_key_to_translation_key(program) assert RE_TRANSLATION_KEY.match(translation_key) + + +async def test_config_entry_unique_id_migration( + hass: HomeAssistant, + config_entry_v1_2: MockConfigEntry, +) -> None: + """Test that old config entries use the unique id obtained from the JWT subject.""" + config_entry_v1_2.add_to_hass(hass) + + assert config_entry_v1_2.unique_id != "1234567890" + assert config_entry_v1_2.minor_version == 2 + + await hass.config_entries.async_setup(config_entry_v1_2.entry_id) + await hass.async_block_till_done() + + assert config_entry_v1_2.unique_id == "1234567890" + assert config_entry_v1_2.minor_version == 3 From df5f1505317d563fc1904f7f822ebdf8bdf8ee3d Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 30 Apr 2025 16:25:45 +0200 Subject: [PATCH 35/37] Cleanup samsungtv coordinator (#143949) --- homeassistant/components/samsungtv/coordinator.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/samsungtv/coordinator.py b/homeassistant/components/samsungtv/coordinator.py index 443e62b13fb5f0..ed3c24946abc6f 100644 --- a/homeassistant/components/samsungtv/coordinator.py +++ b/homeassistant/components/samsungtv/coordinator.py @@ -44,7 +44,7 @@ def __init__( async def _async_update_data(self) -> None: """Fetch data from SamsungTV bridge.""" - if self.bridge.auth_failed or self.hass.is_stopping: + if self.bridge.auth_failed: return old_state = self.is_on if self.bridge.power_off_in_progress: From 101b0737931a2f69356a566c523a6a8666f74fb5 Mon Sep 17 00:00:00 2001 From: Timothy <6560631+TimoPtr@users.noreply.github.com> Date: Wed, 30 Apr 2025 16:57:26 +0200 Subject: [PATCH 36/37] Use Lokalise references to remove duplicates in todo component (#143967) --- homeassistant/components/todo/strings.json | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/todo/strings.json b/homeassistant/components/todo/strings.json index cffb22e89f0ca3..f02842349adea5 100644 --- a/homeassistant/components/todo/strings.json +++ b/homeassistant/components/todo/strings.json @@ -55,16 +55,16 @@ "description": "A status or confirmation of the to-do item." }, "due_date": { - "name": "Due date", - "description": "The date the to-do item is expected to be completed." + "name": "[%key:component::todo::services::add_item::fields::due_date::name%]", + "description": "[%key:component::todo::services::add_item::fields::due_date::description%]" }, "due_datetime": { - "name": "Due date and time", - "description": "The date and time the to-do item is expected to be completed." + "name": "[%key:component::todo::services::add_item::fields::due_datetime::name%]", + "description": "[%key:component::todo::services::add_item::fields::due_datetime::description%]" }, "description": { - "name": "Description", - "description": "A more complete description of the to-do item than provided by the item name." + "name": "[%key:component::todo::services::add_item::fields::description::name%]", + "description": "[%key:component::todo::services::add_item::fields::description::description%]" } } }, From 837592381a27f8cd63d00b5d16266785411a4f62 Mon Sep 17 00:00:00 2001 From: Paul Bottein Date: Wed, 30 Apr 2025 17:22:05 +0200 Subject: [PATCH 37/37] Update frontend to 20250430.1 (#143965) --- 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 64b49588ba115a..113d4c81782c49 100644 --- a/homeassistant/components/frontend/manifest.json +++ b/homeassistant/components/frontend/manifest.json @@ -20,5 +20,5 @@ "documentation": "https://www.home-assistant.io/integrations/frontend", "integration_type": "system", "quality_scale": "internal", - "requirements": ["home-assistant-frontend==20250411.0"] + "requirements": ["home-assistant-frontend==20250430.1"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 6b3f3521be02da..35a52c6204f1eb 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -38,7 +38,7 @@ habluetooth==3.45.0 hass-nabucasa==0.96.0 hassil==2.2.3 home-assistant-bluetooth==1.13.1 -home-assistant-frontend==20250411.0 +home-assistant-frontend==20250430.1 home-assistant-intents==2025.3.28 httpx==0.28.1 ifaddr==0.2.0 diff --git a/requirements_all.txt b/requirements_all.txt index 88c5df7384cbb2..2130ebd6457a2d 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1161,7 +1161,7 @@ hole==0.8.0 holidays==0.70 # homeassistant.components.frontend -home-assistant-frontend==20250411.0 +home-assistant-frontend==20250430.1 # homeassistant.components.conversation home-assistant-intents==2025.3.28 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 0e97ada4d9eec6..f4063e3ae2a7b8 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -991,7 +991,7 @@ hole==0.8.0 holidays==0.70 # homeassistant.components.frontend -home-assistant-frontend==20250411.0 +home-assistant-frontend==20250430.1 # homeassistant.components.conversation home-assistant-intents==2025.3.28