From f42b137c1b3071bbfafa1d026a153e082ad24054 Mon Sep 17 00:00:00 2001 From: tdfountain <174762217+tdfountain@users.noreply.github.com> Date: Sat, 19 Apr 2025 10:27:03 -0700 Subject: [PATCH 01/70] Add missing data description strings of config flow for NUT (#143267) --- homeassistant/components/nut/strings.json | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/homeassistant/components/nut/strings.json b/homeassistant/components/nut/strings.json index fb49029d69f..dff568944b7 100644 --- a/homeassistant/components/nut/strings.json +++ b/homeassistant/components/nut/strings.json @@ -20,6 +20,9 @@ "title": "Choose the NUT server UPS to monitor", "data": { "alias": "NUT server UPS name" + }, + "data_description": { + "alias": "The UPS name configured on the NUT server." } }, "reauth_confirm": { @@ -27,6 +30,10 @@ "data": { "username": "[%key:common::config_flow::data::username%]", "password": "[%key:common::config_flow::data::password%]" + }, + "data_description": { + "username": "[%key:component::nut::config::step::user::data_description::username%]", + "password": "[%key:component::nut::config::step::user::data_description::password%]" } }, "reconfigure": { @@ -48,6 +55,9 @@ "title": "[%key:component::nut::config::step::ups::title%]", "data": { "alias": "[%key:component::nut::config::step::ups::data::alias%]" + }, + "data_description": { + "alias": "[%key:component::nut::config::step::ups::data_description::alias%]" } } }, From 99e1245c9bd1fe9043df349cc11d2f0ec5d388f4 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Sat, 19 Apr 2025 20:22:04 +0200 Subject: [PATCH 02/70] Use common state for "Error" in `vacuum` (#143265) --- homeassistant/components/vacuum/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/vacuum/strings.json b/homeassistant/components/vacuum/strings.json index 1efaf87e748..f9e7a2844cd 100644 --- a/homeassistant/components/vacuum/strings.json +++ b/homeassistant/components/vacuum/strings.json @@ -23,7 +23,7 @@ "state": { "cleaning": "Cleaning", "docked": "Docked", - "error": "Error", + "error": "[%key:common::state::error%]", "idle": "[%key:common::state::idle%]", "off": "[%key:common::state::off%]", "on": "[%key:common::state::on%]", From 16c72c491ddfd1f8c64beab5f891d16878f44438 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Sat, 19 Apr 2025 20:23:51 +0200 Subject: [PATCH 03/70] Use common state for "Error" in `lawn_mower` (#143266) --- homeassistant/components/lawn_mower/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/lawn_mower/strings.json b/homeassistant/components/lawn_mower/strings.json index ebaea4ffd6a..9cc56b8a11e 100644 --- a/homeassistant/components/lawn_mower/strings.json +++ b/homeassistant/components/lawn_mower/strings.json @@ -4,7 +4,7 @@ "_": { "name": "[%key:component::lawn_mower::title%]", "state": { - "error": "Error", + "error": "[%key:common::state::error%]", "paused": "[%key:common::state::paused%]", "mowing": "Mowing", "docked": "Docked", From 7674f6b5aa603a8ff364b3c6fcb95ad4073ab187 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ab=C3=ADlio=20Costa?= Date: Sat, 19 Apr 2025 19:56:56 +0100 Subject: [PATCH 04/70] Turn on after setting parameters in Govee Light Local (#143233) --- .../components/govee_light_local/light.py | 6 +- .../govee_light_local/test_light.py | 83 ++++++++++++++++++- 2 files changed, 84 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/govee_light_local/light.py b/homeassistant/components/govee_light_local/light.py index 984654477e9..c5c8ed42ad5 100644 --- a/homeassistant/components/govee_light_local/light.py +++ b/homeassistant/components/govee_light_local/light.py @@ -157,9 +157,6 @@ class GoveeLight(CoordinatorEntity[GoveeLocalApiCoordinator], LightEntity): async def async_turn_on(self, **kwargs: Any) -> None: """Turn the device on.""" - if not self.is_on or not kwargs: - await self.coordinator.turn_on(self._device) - if ATTR_BRIGHTNESS in kwargs: brightness: int = int((float(kwargs[ATTR_BRIGHTNESS]) / 255.0) * 100.0) await self.coordinator.set_brightness(self._device, brightness) @@ -187,6 +184,9 @@ class GoveeLight(CoordinatorEntity[GoveeLocalApiCoordinator], LightEntity): self._save_last_color_state() await self.coordinator.set_scene(self._device, effect) + if not self.is_on or not kwargs: + await self.coordinator.turn_on(self._device) + self.async_write_ha_state() async def async_turn_off(self, **kwargs: Any) -> None: diff --git a/tests/components/govee_light_local/test_light.py b/tests/components/govee_light_local/test_light.py index c5dde6a9b9e..89d9cc61e37 100644 --- a/tests/components/govee_light_local/test_light.py +++ b/tests/components/govee_light_local/test_light.py @@ -1,12 +1,20 @@ """Test Govee light local.""" from errno import EADDRINUSE, ENETDOWN -from unittest.mock import AsyncMock, MagicMock, patch +from unittest.mock import AsyncMock, MagicMock, call, patch from govee_local_api import GoveeDevice +import pytest from homeassistant.components.govee_light_local.const import DOMAIN -from homeassistant.components.light import ATTR_SUPPORTED_COLOR_MODES, ColorMode +from homeassistant.components.light import ( + ATTR_BRIGHTNESS_PCT, + ATTR_COLOR_TEMP_KELVIN, + ATTR_EFFECT, + ATTR_RGB_COLOR, + ATTR_SUPPORTED_COLOR_MODES, + ColorMode, +) from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant @@ -224,6 +232,77 @@ async def test_light_on_off(hass: HomeAssistant, mock_govee_api: MagicMock) -> N mock_govee_api.turn_on_off.assert_awaited_with(mock_govee_api.devices[0], False) +@pytest.mark.parametrize( + ("attribute", "value", "mock_call", "mock_call_args", "mock_call_kwargs"), + [ + ( + ATTR_RGB_COLOR, + [100, 255, 50], + "set_color", + [], + {"temperature": None, "rgb": (100, 255, 50)}, + ), + ( + ATTR_COLOR_TEMP_KELVIN, + 4400, + "set_color", + [], + {"temperature": 4400, "rgb": None}, + ), + (ATTR_EFFECT, "sunrise", "set_scene", ["sunrise"], {}), + ], +) +async def test_turn_on_call_order( + hass: HomeAssistant, + mock_govee_api: MagicMock, + attribute: str, + value: str | int | list[int], + mock_call: str, + mock_call_args: list[str], + mock_call_kwargs: dict[str, any], +) -> None: + """Test that turn_on is called after set_brightness/set_color/set_preset.""" + mock_govee_api.devices = [ + GoveeDevice( + controller=mock_govee_api, + ip="192.168.1.100", + fingerprint="asdawdqwdqwd", + sku="H615A", + capabilities=SCENE_CAPABILITIES, + ) + ] + + entry = MockConfigEntry(domain=DOMAIN) + entry.add_to_hass(hass) + + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert len(hass.states.async_all()) == 1 + + light = hass.states.get("light.H615A") + assert light is not None + assert light.state == "off" + + await hass.services.async_call( + "light", + "turn_on", + {"entity_id": light.entity_id, ATTR_BRIGHTNESS_PCT: 50, attribute: value}, + blocking=True, + ) + await hass.async_block_till_done() + + mock_govee_api.assert_has_calls( + [ + call.set_brightness(mock_govee_api.devices[0], 50), + getattr(call, mock_call)( + mock_govee_api.devices[0], *mock_call_args, **mock_call_kwargs + ), + call.turn_on_off(mock_govee_api.devices[0], True), + ] + ) + + async def test_light_brightness(hass: HomeAssistant, mock_govee_api: MagicMock) -> None: """Test changing brightness.""" mock_govee_api.devices = [ From 626eb770609a045155b33cdfbc1a5f7d51dbb7f0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ab=C3=ADlio=20Costa?= Date: Sat, 19 Apr 2025 20:11:36 +0100 Subject: [PATCH 05/70] Replace literals with consts in Govee Light Local tests (#143280) --- .../govee_light_local/test_light.py | 138 +++++++++--------- 1 file changed, 70 insertions(+), 68 deletions(-) diff --git a/tests/components/govee_light_local/test_light.py b/tests/components/govee_light_local/test_light.py index 89d9cc61e37..40748c0598e 100644 --- a/tests/components/govee_light_local/test_light.py +++ b/tests/components/govee_light_local/test_light.py @@ -8,14 +8,17 @@ import pytest from homeassistant.components.govee_light_local.const import DOMAIN from homeassistant.components.light import ( + ATTR_BRIGHTNESS, ATTR_BRIGHTNESS_PCT, ATTR_COLOR_TEMP_KELVIN, ATTR_EFFECT, ATTR_RGB_COLOR, ATTR_SUPPORTED_COLOR_MODES, + DOMAIN as LIGHT_DOMAIN, ColorMode, ) from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import SERVICE_TURN_OFF, SERVICE_TURN_ON from homeassistant.core import HomeAssistant from .conftest import DEFAULT_CAPABILITIES, SCENE_CAPABILITIES @@ -205,8 +208,8 @@ async def test_light_on_off(hass: HomeAssistant, mock_govee_api: MagicMock) -> N assert light.state == "off" await hass.services.async_call( - "light", - "turn_on", + LIGHT_DOMAIN, + SERVICE_TURN_ON, {"entity_id": light.entity_id}, blocking=True, ) @@ -219,8 +222,8 @@ async def test_light_on_off(hass: HomeAssistant, mock_govee_api: MagicMock) -> N # Turn off await hass.services.async_call( - "light", - "turn_off", + LIGHT_DOMAIN, + SERVICE_TURN_OFF, {"entity_id": light.entity_id}, blocking=True, ) @@ -285,8 +288,8 @@ async def test_turn_on_call_order( assert light.state == "off" await hass.services.async_call( - "light", - "turn_on", + LIGHT_DOMAIN, + SERVICE_TURN_ON, {"entity_id": light.entity_id, ATTR_BRIGHTNESS_PCT: 50, attribute: value}, blocking=True, ) @@ -328,8 +331,8 @@ async def test_light_brightness(hass: HomeAssistant, mock_govee_api: MagicMock) assert light.state == "off" await hass.services.async_call( - "light", - "turn_on", + LIGHT_DOMAIN, + SERVICE_TURN_ON, {"entity_id": light.entity_id, "brightness_pct": 50}, blocking=True, ) @@ -339,12 +342,12 @@ async def test_light_brightness(hass: HomeAssistant, mock_govee_api: MagicMock) assert light is not None assert light.state == "on" mock_govee_api.set_brightness.assert_awaited_with(mock_govee_api.devices[0], 50) - assert light.attributes["brightness"] == 127 + assert light.attributes[ATTR_BRIGHTNESS] == 127 await hass.services.async_call( - "light", - "turn_on", - {"entity_id": light.entity_id, "brightness": 255}, + LIGHT_DOMAIN, + SERVICE_TURN_ON, + {"entity_id": light.entity_id, ATTR_BRIGHTNESS: 255}, blocking=True, ) await hass.async_block_till_done() @@ -352,13 +355,13 @@ async def test_light_brightness(hass: HomeAssistant, mock_govee_api: MagicMock) light = hass.states.get("light.H615A") assert light is not None assert light.state == "on" - assert light.attributes["brightness"] == 255 + assert light.attributes[ATTR_BRIGHTNESS] == 255 mock_govee_api.set_brightness.assert_awaited_with(mock_govee_api.devices[0], 100) await hass.services.async_call( - "light", - "turn_on", - {"entity_id": light.entity_id, "brightness": 255}, + LIGHT_DOMAIN, + SERVICE_TURN_ON, + {"entity_id": light.entity_id, ATTR_BRIGHTNESS: 255}, blocking=True, ) await hass.async_block_till_done() @@ -366,7 +369,7 @@ async def test_light_brightness(hass: HomeAssistant, mock_govee_api: MagicMock) light = hass.states.get("light.H615A") assert light is not None assert light.state == "on" - assert light.attributes["brightness"] == 255 + assert light.attributes[ATTR_BRIGHTNESS] == 255 mock_govee_api.set_brightness.assert_awaited_with(mock_govee_api.devices[0], 100) @@ -395,9 +398,9 @@ async def test_light_color(hass: HomeAssistant, mock_govee_api: MagicMock) -> No assert light.state == "off" await hass.services.async_call( - "light", - "turn_on", - {"entity_id": light.entity_id, "rgb_color": [100, 255, 50]}, + LIGHT_DOMAIN, + SERVICE_TURN_ON, + {"entity_id": light.entity_id, ATTR_RGB_COLOR: [100, 255, 50]}, blocking=True, ) await hass.async_block_till_done() @@ -405,7 +408,7 @@ async def test_light_color(hass: HomeAssistant, mock_govee_api: MagicMock) -> No light = hass.states.get("light.H615A") assert light is not None assert light.state == "on" - assert light.attributes["rgb_color"] == (100, 255, 50) + assert light.attributes[ATTR_RGB_COLOR] == (100, 255, 50) assert light.attributes["color_mode"] == ColorMode.RGB mock_govee_api.set_color.assert_awaited_with( @@ -413,8 +416,8 @@ async def test_light_color(hass: HomeAssistant, mock_govee_api: MagicMock) -> No ) await hass.services.async_call( - "light", - "turn_on", + LIGHT_DOMAIN, + SERVICE_TURN_ON, {"entity_id": light.entity_id, "kelvin": 4400}, blocking=True, ) @@ -457,9 +460,9 @@ async def test_scene_on(hass: HomeAssistant, mock_govee_api: MagicMock) -> None: assert light.state == "off" await hass.services.async_call( - "light", - "turn_on", - {"entity_id": light.entity_id, "effect": "sunrise"}, + LIGHT_DOMAIN, + SERVICE_TURN_ON, + {"entity_id": light.entity_id, ATTR_EFFECT: "sunrise"}, blocking=True, ) await hass.async_block_till_done() @@ -467,7 +470,7 @@ async def test_scene_on(hass: HomeAssistant, mock_govee_api: MagicMock) -> None: light = hass.states.get("light.H615A") assert light is not None assert light.state == "on" - assert light.attributes["effect"] == "sunrise" + assert light.attributes[ATTR_EFFECT] == "sunrise" mock_govee_api.turn_on_off.assert_awaited_with(mock_govee_api.devices[0], True) @@ -501,16 +504,16 @@ async def test_scene_restore_rgb( # Set initial color await hass.services.async_call( - "light", - "turn_on", - {"entity_id": light.entity_id, "rgb_color": initial_color}, + LIGHT_DOMAIN, + SERVICE_TURN_ON, + {"entity_id": light.entity_id, ATTR_RGB_COLOR: initial_color}, blocking=True, ) await hass.async_block_till_done() await hass.services.async_call( - "light", - "turn_on", - {"entity_id": light.entity_id, "brightness": 255}, + LIGHT_DOMAIN, + SERVICE_TURN_ON, + {"entity_id": light.entity_id, ATTR_BRIGHTNESS: 255}, blocking=True, ) await hass.async_block_till_done() @@ -518,15 +521,15 @@ async def test_scene_restore_rgb( light = hass.states.get("light.H615A") assert light is not None assert light.state == "on" - assert light.attributes["rgb_color"] == initial_color - assert light.attributes["brightness"] == 255 + assert light.attributes[ATTR_RGB_COLOR] == initial_color + assert light.attributes[ATTR_BRIGHTNESS] == 255 mock_govee_api.turn_on_off.assert_awaited_with(mock_govee_api.devices[0], True) # Activate scene await hass.services.async_call( - "light", - "turn_on", - {"entity_id": light.entity_id, "effect": "sunrise"}, + LIGHT_DOMAIN, + SERVICE_TURN_ON, + {"entity_id": light.entity_id, ATTR_EFFECT: "sunrise"}, blocking=True, ) await hass.async_block_till_done() @@ -534,14 +537,14 @@ async def test_scene_restore_rgb( light = hass.states.get("light.H615A") assert light is not None assert light.state == "on" - assert light.attributes["effect"] == "sunrise" + assert light.attributes[ATTR_EFFECT] == "sunrise" mock_govee_api.turn_on_off.assert_awaited_with(mock_govee_api.devices[0], True) # Deactivate scene await hass.services.async_call( - "light", - "turn_on", - {"entity_id": light.entity_id, "effect": "none"}, + LIGHT_DOMAIN, + SERVICE_TURN_ON, + {"entity_id": light.entity_id, ATTR_EFFECT: "none"}, blocking=True, ) await hass.async_block_till_done() @@ -549,9 +552,9 @@ async def test_scene_restore_rgb( light = hass.states.get("light.H615A") assert light is not None assert light.state == "on" - assert light.attributes["effect"] is None - assert light.attributes["rgb_color"] == initial_color - assert light.attributes["brightness"] == 255 + assert light.attributes[ATTR_EFFECT] is None + assert light.attributes[ATTR_RGB_COLOR] == initial_color + assert light.attributes[ATTR_BRIGHTNESS] == 255 async def test_scene_restore_temperature( @@ -584,8 +587,8 @@ async def test_scene_restore_temperature( # Set initial color await hass.services.async_call( - "light", - "turn_on", + LIGHT_DOMAIN, + SERVICE_TURN_ON, {"entity_id": light.entity_id, "color_temp_kelvin": initial_color}, blocking=True, ) @@ -599,9 +602,9 @@ async def test_scene_restore_temperature( # Activate scene await hass.services.async_call( - "light", - "turn_on", - {"entity_id": light.entity_id, "effect": "sunrise"}, + LIGHT_DOMAIN, + SERVICE_TURN_ON, + {"entity_id": light.entity_id, ATTR_EFFECT: "sunrise"}, blocking=True, ) await hass.async_block_till_done() @@ -609,14 +612,14 @@ async def test_scene_restore_temperature( light = hass.states.get("light.H615A") assert light is not None assert light.state == "on" - assert light.attributes["effect"] == "sunrise" + assert light.attributes[ATTR_EFFECT] == "sunrise" mock_govee_api.set_scene.assert_awaited_with(mock_govee_api.devices[0], "sunrise") # Deactivate scene await hass.services.async_call( - "light", - "turn_on", - {"entity_id": light.entity_id, "effect": "none"}, + LIGHT_DOMAIN, + SERVICE_TURN_ON, + {"entity_id": light.entity_id, ATTR_EFFECT: "none"}, blocking=True, ) await hass.async_block_till_done() @@ -624,7 +627,7 @@ async def test_scene_restore_temperature( light = hass.states.get("light.H615A") assert light is not None assert light.state == "on" - assert light.attributes["effect"] is None + assert light.attributes[ATTR_EFFECT] is None assert light.attributes["color_temp_kelvin"] == initial_color @@ -656,16 +659,16 @@ async def test_scene_none(hass: HomeAssistant, mock_govee_api: MagicMock) -> Non # Set initial color await hass.services.async_call( - "light", - "turn_on", - {"entity_id": light.entity_id, "rgb_color": initial_color}, + LIGHT_DOMAIN, + SERVICE_TURN_ON, + {"entity_id": light.entity_id, ATTR_RGB_COLOR: initial_color}, blocking=True, ) await hass.async_block_till_done() await hass.services.async_call( - "light", - "turn_on", - {"entity_id": light.entity_id, "brightness": 255}, + LIGHT_DOMAIN, + SERVICE_TURN_ON, + {"entity_id": light.entity_id, ATTR_BRIGHTNESS: 255}, blocking=True, ) await hass.async_block_till_done() @@ -673,21 +676,20 @@ async def test_scene_none(hass: HomeAssistant, mock_govee_api: MagicMock) -> Non light = hass.states.get("light.H615A") assert light is not None assert light.state == "on" - assert light.attributes["rgb_color"] == initial_color - assert light.attributes["brightness"] == 255 + assert light.attributes[ATTR_RGB_COLOR] == initial_color + assert light.attributes[ATTR_BRIGHTNESS] == 255 mock_govee_api.turn_on_off.assert_awaited_with(mock_govee_api.devices[0], True) # Activate scene await hass.services.async_call( - "light", - "turn_on", - {"entity_id": light.entity_id, "effect": "none"}, + LIGHT_DOMAIN, + SERVICE_TURN_ON, + {"entity_id": light.entity_id, ATTR_EFFECT: "none"}, blocking=True, ) await hass.async_block_till_done() - light = hass.states.get("light.H615A") assert light is not None assert light.state == "on" - assert light.attributes["effect"] is None + assert light.attributes[ATTR_EFFECT] is None mock_govee_api.set_scene.assert_not_called() From a9e77dc0db31fba2a5ad58f7f071ab9378ab011f Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Sat, 19 Apr 2025 21:12:14 +0200 Subject: [PATCH 06/70] Use common state for "Error", fix sentence-casing in `tplink_omada` (#143278) * Use common state for "Error", fix sentence-casing in `tplink_omada` - replace "Error" with common state reference - correct missing sentence-casing in several strings * Update test_switch.ambr --- .../components/tplink_omada/strings.json | 20 +++++++++---------- .../tplink_omada/snapshots/test_switch.ambr | 6 +++--- 2 files changed, 13 insertions(+), 13 deletions(-) diff --git a/homeassistant/components/tplink_omada/strings.json b/homeassistant/components/tplink_omada/strings.json index 73cea692dbf..99c509a73a7 100644 --- a/homeassistant/components/tplink_omada/strings.json +++ b/homeassistant/components/tplink_omada/strings.json @@ -24,14 +24,14 @@ "username": "[%key:common::config_flow::data::username%]", "password": "[%key:common::config_flow::data::password%]" }, - "title": "Update TP-Link Omada Credentials", + "title": "Update TP-Link Omada credentials", "description": "The provided credentials have stopped working. Please update them." } }, "error": { "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", - "unsupported_controller": "Omada Controller version not supported.", + "unsupported_controller": "Omada controller version not supported.", "unknown": "[%key:common::config_flow::error::unknown%]", "no_sites_found": "No sites found which the user can manage." }, @@ -46,31 +46,31 @@ "name": "Port {port_name} PoE" }, "wan_connect_ipv4": { - "name": "Port {port_name} Internet Connected" + "name": "Port {port_name} Internet connected" }, "wan_connect_ipv6": { - "name": "Port {port_name} Internet Connected (IPv6)" + "name": "Port {port_name} Internet connected (IPv6)" } }, "binary_sensor": { "wan_link": { - "name": "Port {port_name} Internet Link" + "name": "Port {port_name} Internet link" }, "online_detection": { - "name": "Port {port_name} Online Detection" + "name": "Port {port_name} online detection" }, "lan_status": { - "name": "Port {port_name} LAN Status" + "name": "Port {port_name} LAN status" }, "poe_delivery": { - "name": "Port {port_name} PoE Delivery" + "name": "Port {port_name} PoE delivery" } }, "sensor": { "device_status": { "name": "Device status", "state": { - "error": "Error", + "error": "[%key:common::state::error%]", "disconnected": "[%key:common::state::disconnected%]", "connected": "[%key:common::state::connected%]", "pending": "Pending", @@ -91,7 +91,7 @@ "services": { "reconnect_client": { "name": "Reconnect wireless client", - "description": "Tries to get wireless client to reconnect to Omada Network.", + "description": "Tries to get wireless client to reconnect to Omada network.", "fields": { "mac": { "name": "MAC address", diff --git a/tests/components/tplink_omada/snapshots/test_switch.ambr b/tests/components/tplink_omada/snapshots/test_switch.ambr index dde196deaaf..eae97f2aae1 100644 --- a/tests/components/tplink_omada/snapshots/test_switch.ambr +++ b/tests/components/tplink_omada/snapshots/test_switch.ambr @@ -2,7 +2,7 @@ # name: test_gateway_api_fail_disables_switch_entities StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'Test Router Port 4 Internet Connected', + 'friendly_name': 'Test Router Port 4 Internet connected', }), 'context': , 'entity_id': 'switch.test_router_port_4_internet_connected', @@ -15,7 +15,7 @@ # name: test_gateway_connect_ipv4_switch StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'Test Router Port 4 Internet Connected', + 'friendly_name': 'Test Router Port 4 Internet connected', }), 'context': , 'entity_id': 'switch.test_router_port_4_internet_connected', @@ -28,7 +28,7 @@ # name: test_gateway_port_change_disables_switch_entities StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'Test Router Port 4 Internet Connected', + 'friendly_name': 'Test Router Port 4 Internet connected', }), 'context': , 'entity_id': 'switch.test_router_port_4_internet_connected', From 21f9ad399463c08ed2410b54b572da2f548890a9 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Sat, 19 Apr 2025 21:13:09 +0200 Subject: [PATCH 07/70] Use common state for "Error" in `home_connect` (#143276) --- homeassistant/components/home_connect/strings.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/home_connect/strings.json b/homeassistant/components/home_connect/strings.json index 070dcf34f9c..d16459bc594 100644 --- a/homeassistant/components/home_connect/strings.json +++ b/homeassistant/components/home_connect/strings.json @@ -1536,7 +1536,7 @@ "pause": "[%key:common::state::paused%]", "actionrequired": "Action required", "finished": "Finished", - "error": "Error", + "error": "[%key:common::state::error%]", "aborting": "Aborting" } }, @@ -1587,7 +1587,7 @@ "streaminglocal": "Streaming local", "streamingcloud": "Streaming cloud", "streaminglocal_and_cloud": "Streaming local and cloud", - "error": "Error" + "error": "[%key:common::state::error%]" } }, "last_selected_map": { From 84a8c1312f384f223cbe3862fef7cb172df4ef3d Mon Sep 17 00:00:00 2001 From: Thomas55555 <59625598+Thomas55555@users.noreply.github.com> Date: Sat, 19 Apr 2025 21:13:24 +0200 Subject: [PATCH 08/70] Add entity categories to Husqvarna Automower sensors (#143277) --- homeassistant/components/husqvarna_automower/sensor.py | 3 +++ .../husqvarna_automower/snapshots/test_sensor.ambr | 6 +++--- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/husqvarna_automower/sensor.py b/homeassistant/components/husqvarna_automower/sensor.py index d7a83c82185..8ef91e646ce 100644 --- a/homeassistant/components/husqvarna_automower/sensor.py +++ b/homeassistant/components/husqvarna_automower/sensor.py @@ -292,6 +292,7 @@ MOWER_SENSOR_TYPES: tuple[AutomowerSensorEntityDescription, ...] = ( AutomowerSensorEntityDescription( key="cutting_blade_usage_time", translation_key="cutting_blade_usage_time", + entity_category=EntityCategory.DIAGNOSTIC, state_class=SensorStateClass.TOTAL, device_class=SensorDeviceClass.DURATION, native_unit_of_measurement=UnitOfTime.SECONDS, @@ -302,6 +303,7 @@ MOWER_SENSOR_TYPES: tuple[AutomowerSensorEntityDescription, ...] = ( AutomowerSensorEntityDescription( key="downtime", translation_key="downtime", + entity_category=EntityCategory.DIAGNOSTIC, state_class=SensorStateClass.TOTAL, device_class=SensorDeviceClass.DURATION, entity_registry_enabled_default=False, @@ -386,6 +388,7 @@ MOWER_SENSOR_TYPES: tuple[AutomowerSensorEntityDescription, ...] = ( AutomowerSensorEntityDescription( key="uptime", translation_key="uptime", + entity_category=EntityCategory.DIAGNOSTIC, state_class=SensorStateClass.TOTAL, device_class=SensorDeviceClass.DURATION, entity_registry_enabled_default=False, diff --git a/tests/components/husqvarna_automower/snapshots/test_sensor.ambr b/tests/components/husqvarna_automower/snapshots/test_sensor.ambr index 92320de6fdb..c7d2674f2d4 100644 --- a/tests/components/husqvarna_automower/snapshots/test_sensor.ambr +++ b/tests/components/husqvarna_automower/snapshots/test_sensor.ambr @@ -65,7 +65,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'sensor.test_mower_1_cutting_blade_usage_time', 'has_entity_name': True, 'hidden_by': None, @@ -120,7 +120,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'sensor.test_mower_1_downtime', 'has_entity_name': True, 'hidden_by': None, @@ -1280,7 +1280,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'sensor.test_mower_1_uptime', 'has_entity_name': True, 'hidden_by': None, From 9de136789cb3f8ddffd59e896fc044deab5065ca Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Sat, 19 Apr 2025 21:13:52 +0200 Subject: [PATCH 09/70] Use common state for "Error" in `blue_current` (#143274) --- homeassistant/components/blue_current/strings.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/blue_current/strings.json b/homeassistant/components/blue_current/strings.json index b90a4792f65..a8a9aff7f08 100644 --- a/homeassistant/components/blue_current/strings.json +++ b/homeassistant/components/blue_current/strings.json @@ -30,7 +30,7 @@ "available": "Available", "charging": "[%key:common::state::charging%]", "unavailable": "Unavailable", - "error": "Error", + "error": "[%key:common::state::error%]", "offline": "Offline" } }, @@ -41,7 +41,7 @@ "vehicle_detected": "Detected", "ready": "Ready", "no_power": "No power", - "vehicle_error": "Error" + "vehicle_error": "[%key:common::state::error%]" } }, "actual_v1": { From 012f6b660cc68291c7f402d043cc5560cced7617 Mon Sep 17 00:00:00 2001 From: Thomas55555 <59625598+Thomas55555@users.noreply.github.com> Date: Sat, 19 Apr 2025 21:16:01 +0200 Subject: [PATCH 10/70] Add more states to error sensor in Husqvarna Automower (#143270) * Add more states to error sensor in Husqvarna Automower * Use new common state * tests and duplicates --- .../components/husqvarna_automower/sensor.py | 43 ++---- .../husqvarna_automower/strings.json | 8 +- .../snapshots/test_sensor.ambr | 136 +++++------------- 3 files changed, 56 insertions(+), 131 deletions(-) diff --git a/homeassistant/components/husqvarna_automower/sensor.py b/homeassistant/components/husqvarna_automower/sensor.py index 8ef91e646ce..5ad8ad91b48 100644 --- a/homeassistant/components/husqvarna_automower/sensor.py +++ b/homeassistant/components/husqvarna_automower/sensor.py @@ -40,8 +40,7 @@ PARALLEL_UPDATES = 0 ATTR_WORK_AREA_ID_ASSIGNMENT = "work_area_id_assignment" -ERROR_KEY_LIST = [ - "no_error", +ERROR_KEYS = [ "alarm_mower_in_motion", "alarm_mower_lifted", "alarm_mower_stopped", @@ -50,13 +49,11 @@ ERROR_KEY_LIST = [ "alarm_outside_geofence", "angular_sensor_problem", "battery_problem", - "battery_problem", "battery_restriction_due_to_ambient_temperature", "can_error", "charging_current_too_high", "charging_station_blocked", "charging_system_problem", - "charging_system_problem", "collision_sensor_defect", "collision_sensor_error", "collision_sensor_problem_front", @@ -67,24 +64,18 @@ ERROR_KEY_LIST = [ "connection_changed", "connection_not_changed", "connectivity_problem", - "connectivity_problem", - "connectivity_problem", - "connectivity_problem", - "connectivity_problem", - "connectivity_problem", "connectivity_settings_restored", "cutting_drive_motor_1_defect", "cutting_drive_motor_2_defect", "cutting_drive_motor_3_defect", "cutting_height_blocked", - "cutting_height_problem", "cutting_height_problem_curr", "cutting_height_problem_dir", "cutting_height_problem_drive", + "cutting_height_problem", "cutting_motor_problem", "cutting_stopped_slope_too_steep", "cutting_system_blocked", - "cutting_system_blocked", "cutting_system_imbalance_warning", "cutting_system_major_imbalance", "destination_not_reachable", @@ -92,13 +83,9 @@ ERROR_KEY_LIST = [ "docking_sensor_defect", "electronic_problem", "empty_battery", - MowerStates.ERROR.lower(), - MowerStates.ERROR_AT_POWER_UP.lower(), - MowerStates.FATAL_ERROR.lower(), "folding_cutting_deck_sensor_defect", "folding_sensor_activated", "geofence_problem", - "geofence_problem", "gps_navigation_problem", "guide_1_not_found", "guide_2_not_found", @@ -116,7 +103,6 @@ ERROR_KEY_LIST = [ "lift_sensor_defect", "lifted", "limited_cutting_height_range", - "limited_cutting_height_range", "loop_sensor_defect", "loop_sensor_problem_front", "loop_sensor_problem_left", @@ -129,6 +115,7 @@ ERROR_KEY_LIST = [ "no_accurate_position_from_satellites", "no_confirmed_position", "no_drive", + "no_error", "no_loop_signal", "no_power_in_charging_station", "no_response_from_charger", @@ -139,9 +126,6 @@ ERROR_KEY_LIST = [ "safety_function_faulty", "settings_restored", "sim_card_locked", - "sim_card_locked", - "sim_card_locked", - "sim_card_locked", "sim_card_not_found", "sim_card_requires_pin", "slipped_mower_has_slipped_situation_not_solved_with_moving_pattern", @@ -151,13 +135,6 @@ ERROR_KEY_LIST = [ "stuck_in_charging_station", "switch_cord_problem", "temporary_battery_problem", - "temporary_battery_problem", - "temporary_battery_problem", - "temporary_battery_problem", - "temporary_battery_problem", - "temporary_battery_problem", - "temporary_battery_problem", - "temporary_battery_problem", "tilt_sensor_problem", "too_high_discharge_current", "too_high_internal_current", @@ -189,11 +166,19 @@ ERROR_KEY_LIST = [ "zone_generator_problem", ] -ERROR_STATES = { - MowerStates.ERROR, +ERROR_STATES = [ MowerStates.ERROR_AT_POWER_UP, + MowerStates.ERROR, MowerStates.FATAL_ERROR, -} + MowerStates.OFF, + MowerStates.STOPPED, + MowerStates.WAIT_POWER_UP, + MowerStates.WAIT_UPDATING, +] + +ERROR_KEY_LIST = list( + dict.fromkeys(ERROR_KEYS + [state.lower() for state in ERROR_STATES]) +) RESTRICTED_REASONS: list = [ RestrictedReasons.ALL_WORK_AREAS_COMPLETED, diff --git a/homeassistant/components/husqvarna_automower/strings.json b/homeassistant/components/husqvarna_automower/strings.json index 35ce342867f..015d322c481 100644 --- a/homeassistant/components/husqvarna_automower/strings.json +++ b/homeassistant/components/husqvarna_automower/strings.json @@ -106,10 +106,10 @@ "cutting_drive_motor_2_defect": "Cutting drive motor 2 defect", "cutting_drive_motor_3_defect": "Cutting drive motor 3 defect", "cutting_height_blocked": "Cutting height blocked", - "cutting_height_problem": "Cutting height problem", "cutting_height_problem_curr": "Cutting height problem, curr", "cutting_height_problem_dir": "Cutting height problem, dir", "cutting_height_problem_drive": "Cutting height problem, drive", + "cutting_height_problem": "Cutting height problem", "cutting_motor_problem": "Cutting motor problem", "cutting_stopped_slope_too_steep": "Cutting stopped - slope too steep", "cutting_system_blocked": "Cutting system blocked", @@ -120,8 +120,8 @@ "docking_sensor_defect": "Docking sensor defect", "electronic_problem": "Electronic problem", "empty_battery": "Empty battery", - "error": "Error", "error_at_power_up": "Error at power up", + "error": "[%key:common::state::error%]", "fatal_error": "Fatal error", "folding_cutting_deck_sensor_defect": "Folding cutting deck sensor defect", "folding_sensor_activated": "Folding sensor activated", @@ -159,6 +159,7 @@ "no_loop_signal": "No loop signal", "no_power_in_charging_station": "No power in charging station", "no_response_from_charger": "No response from charger", + "off": "[%key:common::state::off%]", "outside_working_area": "Outside working area", "poor_signal_quality": "Poor signal quality", "reference_station_communication_problem": "Reference station communication problem", @@ -172,6 +173,7 @@ "slope_too_steep": "Slope too steep", "sms_could_not_be_sent": "SMS could not be sent", "stop_button_problem": "STOP button problem", + "stopped": "[%key:common::state::stopped%]", "stuck_in_charging_station": "Stuck in charging station", "switch_cord_problem": "Switch cord problem", "temporary_battery_problem": "Temporary battery problem", @@ -187,6 +189,8 @@ "unexpected_cutting_height_adj": "Unexpected cutting height adjustment", "unexpected_error": "Unexpected error", "upside_down": "Upside down", + "wait_power_up": "Wait power up", + "wait_updating": "Wait updating", "weak_gps_signal": "Weak GPS signal", "wheel_drive_problem_left": "Left wheel drive problem", "wheel_drive_problem_rear_left": "Rear left wheel drive problem", diff --git a/tests/components/husqvarna_automower/snapshots/test_sensor.ambr b/tests/components/husqvarna_automower/snapshots/test_sensor.ambr index c7d2674f2d4..979d40a53d8 100644 --- a/tests/components/husqvarna_automower/snapshots/test_sensor.ambr +++ b/tests/components/husqvarna_automower/snapshots/test_sensor.ambr @@ -171,7 +171,6 @@ 'area_id': None, 'capabilities': dict({ 'options': list([ - 'no_error', 'alarm_mower_in_motion', 'alarm_mower_lifted', 'alarm_mower_stopped', @@ -180,13 +179,11 @@ 'alarm_outside_geofence', 'angular_sensor_problem', 'battery_problem', - 'battery_problem', 'battery_restriction_due_to_ambient_temperature', 'can_error', 'charging_current_too_high', 'charging_station_blocked', 'charging_system_problem', - 'charging_system_problem', 'collision_sensor_defect', 'collision_sensor_error', 'collision_sensor_problem_front', @@ -197,24 +194,18 @@ 'connection_changed', 'connection_not_changed', 'connectivity_problem', - 'connectivity_problem', - 'connectivity_problem', - 'connectivity_problem', - 'connectivity_problem', - 'connectivity_problem', 'connectivity_settings_restored', 'cutting_drive_motor_1_defect', 'cutting_drive_motor_2_defect', 'cutting_drive_motor_3_defect', 'cutting_height_blocked', - 'cutting_height_problem', 'cutting_height_problem_curr', 'cutting_height_problem_dir', 'cutting_height_problem_drive', + 'cutting_height_problem', 'cutting_motor_problem', 'cutting_stopped_slope_too_steep', 'cutting_system_blocked', - 'cutting_system_blocked', 'cutting_system_imbalance_warning', 'cutting_system_major_imbalance', 'destination_not_reachable', @@ -222,13 +213,9 @@ 'docking_sensor_defect', 'electronic_problem', 'empty_battery', - 'error', - 'error_at_power_up', - 'fatal_error', 'folding_cutting_deck_sensor_defect', 'folding_sensor_activated', 'geofence_problem', - 'geofence_problem', 'gps_navigation_problem', 'guide_1_not_found', 'guide_2_not_found', @@ -246,7 +233,6 @@ 'lift_sensor_defect', 'lifted', 'limited_cutting_height_range', - 'limited_cutting_height_range', 'loop_sensor_defect', 'loop_sensor_problem_front', 'loop_sensor_problem_left', @@ -259,6 +245,7 @@ 'no_accurate_position_from_satellites', 'no_confirmed_position', 'no_drive', + 'no_error', 'no_loop_signal', 'no_power_in_charging_station', 'no_response_from_charger', @@ -269,9 +256,6 @@ 'safety_function_faulty', 'settings_restored', 'sim_card_locked', - 'sim_card_locked', - 'sim_card_locked', - 'sim_card_locked', 'sim_card_not_found', 'sim_card_requires_pin', 'slipped_mower_has_slipped_situation_not_solved_with_moving_pattern', @@ -281,13 +265,6 @@ 'stuck_in_charging_station', 'switch_cord_problem', 'temporary_battery_problem', - 'temporary_battery_problem', - 'temporary_battery_problem', - 'temporary_battery_problem', - 'temporary_battery_problem', - 'temporary_battery_problem', - 'temporary_battery_problem', - 'temporary_battery_problem', 'tilt_sensor_problem', 'too_high_discharge_current', 'too_high_internal_current', @@ -317,6 +294,13 @@ 'wrong_loop_signal', 'wrong_pin_code', 'zone_generator_problem', + 'error_at_power_up', + 'error', + 'fatal_error', + 'off', + 'stopped', + 'wait_power_up', + 'wait_updating', ]), }), 'config_entry_id': , @@ -353,7 +337,6 @@ 'device_class': 'enum', 'friendly_name': 'Test Mower 1 Error', 'options': list([ - 'no_error', 'alarm_mower_in_motion', 'alarm_mower_lifted', 'alarm_mower_stopped', @@ -362,13 +345,11 @@ 'alarm_outside_geofence', 'angular_sensor_problem', 'battery_problem', - 'battery_problem', 'battery_restriction_due_to_ambient_temperature', 'can_error', 'charging_current_too_high', 'charging_station_blocked', 'charging_system_problem', - 'charging_system_problem', 'collision_sensor_defect', 'collision_sensor_error', 'collision_sensor_problem_front', @@ -379,24 +360,18 @@ 'connection_changed', 'connection_not_changed', 'connectivity_problem', - 'connectivity_problem', - 'connectivity_problem', - 'connectivity_problem', - 'connectivity_problem', - 'connectivity_problem', 'connectivity_settings_restored', 'cutting_drive_motor_1_defect', 'cutting_drive_motor_2_defect', 'cutting_drive_motor_3_defect', 'cutting_height_blocked', - 'cutting_height_problem', 'cutting_height_problem_curr', 'cutting_height_problem_dir', 'cutting_height_problem_drive', + 'cutting_height_problem', 'cutting_motor_problem', 'cutting_stopped_slope_too_steep', 'cutting_system_blocked', - 'cutting_system_blocked', 'cutting_system_imbalance_warning', 'cutting_system_major_imbalance', 'destination_not_reachable', @@ -404,13 +379,9 @@ 'docking_sensor_defect', 'electronic_problem', 'empty_battery', - 'error', - 'error_at_power_up', - 'fatal_error', 'folding_cutting_deck_sensor_defect', 'folding_sensor_activated', 'geofence_problem', - 'geofence_problem', 'gps_navigation_problem', 'guide_1_not_found', 'guide_2_not_found', @@ -428,7 +399,6 @@ 'lift_sensor_defect', 'lifted', 'limited_cutting_height_range', - 'limited_cutting_height_range', 'loop_sensor_defect', 'loop_sensor_problem_front', 'loop_sensor_problem_left', @@ -441,6 +411,7 @@ 'no_accurate_position_from_satellites', 'no_confirmed_position', 'no_drive', + 'no_error', 'no_loop_signal', 'no_power_in_charging_station', 'no_response_from_charger', @@ -451,9 +422,6 @@ 'safety_function_faulty', 'settings_restored', 'sim_card_locked', - 'sim_card_locked', - 'sim_card_locked', - 'sim_card_locked', 'sim_card_not_found', 'sim_card_requires_pin', 'slipped_mower_has_slipped_situation_not_solved_with_moving_pattern', @@ -463,13 +431,6 @@ 'stuck_in_charging_station', 'switch_cord_problem', 'temporary_battery_problem', - 'temporary_battery_problem', - 'temporary_battery_problem', - 'temporary_battery_problem', - 'temporary_battery_problem', - 'temporary_battery_problem', - 'temporary_battery_problem', - 'temporary_battery_problem', 'tilt_sensor_problem', 'too_high_discharge_current', 'too_high_internal_current', @@ -499,6 +460,13 @@ 'wrong_loop_signal', 'wrong_pin_code', 'zone_generator_problem', + 'error_at_power_up', + 'error', + 'fatal_error', + 'off', + 'stopped', + 'wait_power_up', + 'wait_updating', ]), }), 'context': , @@ -1449,7 +1417,6 @@ 'area_id': None, 'capabilities': dict({ 'options': list([ - 'no_error', 'alarm_mower_in_motion', 'alarm_mower_lifted', 'alarm_mower_stopped', @@ -1458,13 +1425,11 @@ 'alarm_outside_geofence', 'angular_sensor_problem', 'battery_problem', - 'battery_problem', 'battery_restriction_due_to_ambient_temperature', 'can_error', 'charging_current_too_high', 'charging_station_blocked', 'charging_system_problem', - 'charging_system_problem', 'collision_sensor_defect', 'collision_sensor_error', 'collision_sensor_problem_front', @@ -1475,24 +1440,18 @@ 'connection_changed', 'connection_not_changed', 'connectivity_problem', - 'connectivity_problem', - 'connectivity_problem', - 'connectivity_problem', - 'connectivity_problem', - 'connectivity_problem', 'connectivity_settings_restored', 'cutting_drive_motor_1_defect', 'cutting_drive_motor_2_defect', 'cutting_drive_motor_3_defect', 'cutting_height_blocked', - 'cutting_height_problem', 'cutting_height_problem_curr', 'cutting_height_problem_dir', 'cutting_height_problem_drive', + 'cutting_height_problem', 'cutting_motor_problem', 'cutting_stopped_slope_too_steep', 'cutting_system_blocked', - 'cutting_system_blocked', 'cutting_system_imbalance_warning', 'cutting_system_major_imbalance', 'destination_not_reachable', @@ -1500,13 +1459,9 @@ 'docking_sensor_defect', 'electronic_problem', 'empty_battery', - 'error', - 'error_at_power_up', - 'fatal_error', 'folding_cutting_deck_sensor_defect', 'folding_sensor_activated', 'geofence_problem', - 'geofence_problem', 'gps_navigation_problem', 'guide_1_not_found', 'guide_2_not_found', @@ -1524,7 +1479,6 @@ 'lift_sensor_defect', 'lifted', 'limited_cutting_height_range', - 'limited_cutting_height_range', 'loop_sensor_defect', 'loop_sensor_problem_front', 'loop_sensor_problem_left', @@ -1537,6 +1491,7 @@ 'no_accurate_position_from_satellites', 'no_confirmed_position', 'no_drive', + 'no_error', 'no_loop_signal', 'no_power_in_charging_station', 'no_response_from_charger', @@ -1547,9 +1502,6 @@ 'safety_function_faulty', 'settings_restored', 'sim_card_locked', - 'sim_card_locked', - 'sim_card_locked', - 'sim_card_locked', 'sim_card_not_found', 'sim_card_requires_pin', 'slipped_mower_has_slipped_situation_not_solved_with_moving_pattern', @@ -1559,13 +1511,6 @@ 'stuck_in_charging_station', 'switch_cord_problem', 'temporary_battery_problem', - 'temporary_battery_problem', - 'temporary_battery_problem', - 'temporary_battery_problem', - 'temporary_battery_problem', - 'temporary_battery_problem', - 'temporary_battery_problem', - 'temporary_battery_problem', 'tilt_sensor_problem', 'too_high_discharge_current', 'too_high_internal_current', @@ -1595,6 +1540,13 @@ 'wrong_loop_signal', 'wrong_pin_code', 'zone_generator_problem', + 'error_at_power_up', + 'error', + 'fatal_error', + 'off', + 'stopped', + 'wait_power_up', + 'wait_updating', ]), }), 'config_entry_id': , @@ -1631,7 +1583,6 @@ 'device_class': 'enum', 'friendly_name': 'Test Mower 2 Error', 'options': list([ - 'no_error', 'alarm_mower_in_motion', 'alarm_mower_lifted', 'alarm_mower_stopped', @@ -1640,13 +1591,11 @@ 'alarm_outside_geofence', 'angular_sensor_problem', 'battery_problem', - 'battery_problem', 'battery_restriction_due_to_ambient_temperature', 'can_error', 'charging_current_too_high', 'charging_station_blocked', 'charging_system_problem', - 'charging_system_problem', 'collision_sensor_defect', 'collision_sensor_error', 'collision_sensor_problem_front', @@ -1657,24 +1606,18 @@ 'connection_changed', 'connection_not_changed', 'connectivity_problem', - 'connectivity_problem', - 'connectivity_problem', - 'connectivity_problem', - 'connectivity_problem', - 'connectivity_problem', 'connectivity_settings_restored', 'cutting_drive_motor_1_defect', 'cutting_drive_motor_2_defect', 'cutting_drive_motor_3_defect', 'cutting_height_blocked', - 'cutting_height_problem', 'cutting_height_problem_curr', 'cutting_height_problem_dir', 'cutting_height_problem_drive', + 'cutting_height_problem', 'cutting_motor_problem', 'cutting_stopped_slope_too_steep', 'cutting_system_blocked', - 'cutting_system_blocked', 'cutting_system_imbalance_warning', 'cutting_system_major_imbalance', 'destination_not_reachable', @@ -1682,13 +1625,9 @@ 'docking_sensor_defect', 'electronic_problem', 'empty_battery', - 'error', - 'error_at_power_up', - 'fatal_error', 'folding_cutting_deck_sensor_defect', 'folding_sensor_activated', 'geofence_problem', - 'geofence_problem', 'gps_navigation_problem', 'guide_1_not_found', 'guide_2_not_found', @@ -1706,7 +1645,6 @@ 'lift_sensor_defect', 'lifted', 'limited_cutting_height_range', - 'limited_cutting_height_range', 'loop_sensor_defect', 'loop_sensor_problem_front', 'loop_sensor_problem_left', @@ -1719,6 +1657,7 @@ 'no_accurate_position_from_satellites', 'no_confirmed_position', 'no_drive', + 'no_error', 'no_loop_signal', 'no_power_in_charging_station', 'no_response_from_charger', @@ -1729,9 +1668,6 @@ 'safety_function_faulty', 'settings_restored', 'sim_card_locked', - 'sim_card_locked', - 'sim_card_locked', - 'sim_card_locked', 'sim_card_not_found', 'sim_card_requires_pin', 'slipped_mower_has_slipped_situation_not_solved_with_moving_pattern', @@ -1741,13 +1677,6 @@ 'stuck_in_charging_station', 'switch_cord_problem', 'temporary_battery_problem', - 'temporary_battery_problem', - 'temporary_battery_problem', - 'temporary_battery_problem', - 'temporary_battery_problem', - 'temporary_battery_problem', - 'temporary_battery_problem', - 'temporary_battery_problem', 'tilt_sensor_problem', 'too_high_discharge_current', 'too_high_internal_current', @@ -1777,6 +1706,13 @@ 'wrong_loop_signal', 'wrong_pin_code', 'zone_generator_problem', + 'error_at_power_up', + 'error', + 'fatal_error', + 'off', + 'stopped', + 'wait_power_up', + 'wait_updating', ]), }), 'context': , From ec55f716e18c696d42f9ed344184d6bf963e46df Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Sat, 19 Apr 2025 23:06:44 +0200 Subject: [PATCH 11/70] Use common state for "Error" in `fronius` (#143284) --- homeassistant/components/fronius/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/fronius/strings.json b/homeassistant/components/fronius/strings.json index 36778f2ca5f..6635060dd1c 100644 --- a/homeassistant/components/fronius/strings.json +++ b/homeassistant/components/fronius/strings.json @@ -184,7 +184,7 @@ "running": "Running", "standby": "[%key:common::state::standby%]", "bootloading": "Bootloading", - "error": "Error", + "error": "[%key:common::state::error%]", "idle": "[%key:common::state::idle%]", "ready": "Ready", "sleeping": "Sleeping" From 8f4435019bb3b890737a1b80b621e7c18f8ad96b Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Sat, 19 Apr 2025 23:59:45 +0200 Subject: [PATCH 12/70] Reset logging level in esphome test (#143291) --- tests/components/esphome/test_manager.py | 50 ++++++++++++------------ 1 file changed, 26 insertions(+), 24 deletions(-) diff --git a/tests/components/esphome/test_manager.py b/tests/components/esphome/test_manager.py index a4cef909fcc..9e7810cde8f 100644 --- a/tests/components/esphome/test_manager.py +++ b/tests/components/esphome/test_manager.py @@ -958,33 +958,35 @@ async def test_debug_logging( [APIClient, list[EntityInfo], list[UserService], list[EntityState]], Awaitable[MockConfigEntry], ], + caplog: pytest.LogCaptureFixture, ) -> None: """Test enabling and disabling debug logging.""" - assert await async_setup_component(hass, "logger", {"logger": {}}) - await mock_generic_device_entry( - mock_client=mock_client, - entity_info=[], - user_service=[], - states=[], - ) - await hass.services.async_call( - "logger", - "set_level", - {"homeassistant.components.esphome": "DEBUG"}, - blocking=True, - ) - await hass.async_block_till_done() - mock_client.set_debug.assert_has_calls([call(True)]) + with caplog.at_level(logging.NOTSET, "homeassistant.components.esphome"): + assert await async_setup_component(hass, "logger", {"logger": {}}) + await mock_generic_device_entry( + mock_client=mock_client, + entity_info=[], + user_service=[], + states=[], + ) + await hass.services.async_call( + "logger", + "set_level", + {"homeassistant.components.esphome": "DEBUG"}, + blocking=True, + ) + await hass.async_block_till_done() + mock_client.set_debug.assert_has_calls([call(True)]) - mock_client.reset_mock() - await hass.services.async_call( - "logger", - "set_level", - {"homeassistant.components.esphome": "WARNING"}, - blocking=True, - ) - await hass.async_block_till_done() - mock_client.set_debug.assert_has_calls([call(False)]) + mock_client.reset_mock() + await hass.services.async_call( + "logger", + "set_level", + {"homeassistant.components.esphome": "WARNING"}, + blocking=True, + ) + await hass.async_block_till_done() + mock_client.set_debug.assert_has_calls([call(False)]) async def test_esphome_device_with_dash_in_name_user_services( From e02c2007755d21cd846a4b829c8b5cbaaed90033 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 19 Apr 2025 12:30:43 -1000 Subject: [PATCH 13/70] Bump aiohttp to 3.11.17 (#143290) --- 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 3baebae8a6e..c2ec566a342 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -6,7 +6,7 @@ aiodns==3.2.0 aiohasupervisor==0.3.1b1 aiohttp-asyncmdnsresolver==0.1.1 aiohttp-fast-zlib==0.2.3 -aiohttp==3.11.16 +aiohttp==3.11.17 aiohttp_cors==0.7.0 aiousbwatcher==1.1.1 aiozoneinfo==0.2.3 diff --git a/pyproject.toml b/pyproject.toml index e100863510d..7b3168daec0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -28,7 +28,7 @@ dependencies = [ # change behavior based on presence of supervisor. Deprecated with #127228 # Lib can be removed with 2025.11 "aiohasupervisor==0.3.1b1", - "aiohttp==3.11.16", + "aiohttp==3.11.17", "aiohttp_cors==0.7.0", "aiohttp-fast-zlib==0.2.3", "aiohttp-asyncmdnsresolver==0.1.1", diff --git a/requirements.txt b/requirements.txt index bfc330650e4..a77e47fcf9a 100644 --- a/requirements.txt +++ b/requirements.txt @@ -5,7 +5,7 @@ # Home Assistant Core aiodns==3.2.0 aiohasupervisor==0.3.1b1 -aiohttp==3.11.16 +aiohttp==3.11.17 aiohttp_cors==0.7.0 aiohttp-fast-zlib==0.2.3 aiohttp-asyncmdnsresolver==0.1.1 From b4344a8de2df1fdaa7fc2b0d8689daa659a355f1 Mon Sep 17 00:00:00 2001 From: tdfountain <174762217+tdfountain@users.noreply.github.com> Date: Sat, 19 Apr 2025 16:50:22 -0700 Subject: [PATCH 14/70] Remove unused variable and import in NUT (#143294) Remove unused variable in validate_input --- homeassistant/components/nut/config_flow.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/nut/config_flow.py b/homeassistant/components/nut/config_flow.py index a69d898ff6c..eb13e4a168b 100644 --- a/homeassistant/components/nut/config_flow.py +++ b/homeassistant/components/nut/config_flow.py @@ -19,7 +19,6 @@ from homeassistant.const import ( CONF_PORT, CONF_USERNAME, ) -from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import AbortFlow from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo @@ -56,7 +55,7 @@ def _ups_schema(ups_list: dict[str, str]) -> vol.Schema: return vol.Schema({vol.Required(CONF_ALIAS): vol.In(ups_list)}) -async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> dict[str, Any]: +async def validate_input(data: dict[str, Any]) -> dict[str, Any]: """Validate the user input allows us to connect. Data has the keys from _base_schema with values provided by the user. @@ -303,7 +302,7 @@ class NutConfigFlow(ConfigFlow, domain=DOMAIN): info: dict[str, Any] = {} description_placeholders: dict[str, str] = {} try: - info = await validate_input(self.hass, config) + info = await validate_input(config) except NUTLoginError: errors[CONF_PASSWORD] = "invalid_auth" except NUTError as ex: From f5c0c207ecbe81fb4fd80d3a5285255020f8454e Mon Sep 17 00:00:00 2001 From: tdfountain <174762217+tdfountain@users.noreply.github.com> Date: Sat, 19 Apr 2025 16:53:29 -0700 Subject: [PATCH 15/70] Fix display state to return None instead of STATE_UNKNOWN in NUT (#143297) Fix return value to avoid STATE_UNKNOWN --- homeassistant/components/nut/sensor.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/nut/sensor.py b/homeassistant/components/nut/sensor.py index 5822f7f7b02..ce8c10f8f41 100644 --- a/homeassistant/components/nut/sensor.py +++ b/homeassistant/components/nut/sensor.py @@ -13,7 +13,6 @@ from homeassistant.components.sensor import ( ) from homeassistant.const import ( PERCENTAGE, - STATE_UNKNOWN, EntityCategory, UnitOfApparentPower, UnitOfElectricCurrent, @@ -1120,9 +1119,9 @@ class NUTSensor(NUTBaseEntity, SensorEntity): return status.get(self.entity_description.key) -def _format_display_state(status: dict[str, str]) -> str: +def _format_display_state(status: dict[str, str]) -> str | None: """Return UPS display state.""" try: return ", ".join(STATE_TYPES[state] for state in status[KEY_STATUS].split()) except KeyError: - return STATE_UNKNOWN + return None From 37769b94cd27b1258cd13f15a4ffd61f7bcd5c8e Mon Sep 17 00:00:00 2001 From: tdfountain <174762217+tdfountain@users.noreply.github.com> Date: Sat, 19 Apr 2025 16:55:26 -0700 Subject: [PATCH 16/70] Remove unnecessary persistent notification in test case for NUT (#143298) Remove unnecessary persistent notification --- tests/components/nut/test_config_flow.py | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/tests/components/nut/test_config_flow.py b/tests/components/nut/test_config_flow.py index c0e7f9ffeff..6e308e22faa 100644 --- a/tests/components/nut/test_config_flow.py +++ b/tests/components/nut/test_config_flow.py @@ -5,7 +5,7 @@ from unittest.mock import patch from aionut import NUTError, NUTLoginError -from homeassistant import config_entries, setup +from homeassistant import config_entries from homeassistant.components.nut.config_flow import PASSWORD_NOT_CHANGED from homeassistant.components.nut.const import DOMAIN from homeassistant.const import ( @@ -86,7 +86,6 @@ async def test_form_zeroconf(hass: HomeAssistant) -> None: async def test_form_user_one_alias(hass: HomeAssistant) -> None: """Test we can configure a device with one alias.""" - await setup.async_setup_component(hass, "persistent_notification", {}) result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) @@ -131,8 +130,6 @@ async def test_form_user_one_alias(hass: HomeAssistant) -> None: async def test_form_user_multiple_aliases(hass: HomeAssistant) -> None: """Test we can configure device with multiple aliases.""" - await setup.async_setup_component(hass, "persistent_notification", {}) - config_entry = MockConfigEntry( domain=DOMAIN, data={CONF_HOST: "2.2.2.2", CONF_PORT: 123, CONF_RESOURCES: ["battery.charge"]}, @@ -202,7 +199,6 @@ async def test_form_user_one_alias_with_ignored_entry(hass: HomeAssistant) -> No ) ignored_entry.add_to_hass(hass) - await setup.async_setup_component(hass, "persistent_notification", {}) result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) From 961f8afe5363a36812aea156eea06f2530b9d4e2 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Sun, 20 Apr 2025 02:00:22 +0200 Subject: [PATCH 17/70] Remove debug option in unifiprotect tests (#143296) --- tests/components/unifiprotect/test_binary_sensor.py | 2 +- tests/components/unifiprotect/utils.py | 10 ---------- 2 files changed, 1 insertion(+), 11 deletions(-) diff --git a/tests/components/unifiprotect/test_binary_sensor.py b/tests/components/unifiprotect/test_binary_sensor.py index 3a8d5d952ce..3aa441659b0 100644 --- a/tests/components/unifiprotect/test_binary_sensor.py +++ b/tests/components/unifiprotect/test_binary_sensor.py @@ -407,7 +407,7 @@ async def test_binary_sensor_update_mount_type_garage( ) -> None: """Test binary_sensor motion entity.""" - await init_entry(hass, ufp, [sensor_all], debug=True) + await init_entry(hass, ufp, [sensor_all]) assert_entity_counts(hass, Platform.BINARY_SENSOR, 11, 11) _, entity_id = ids_from_device_description( diff --git a/tests/components/unifiprotect/utils.py b/tests/components/unifiprotect/utils.py index 7dd0362f17c..06ffe16ab87 100644 --- a/tests/components/unifiprotect/utils.py +++ b/tests/components/unifiprotect/utils.py @@ -25,7 +25,6 @@ from homeassistant.const import Platform from homeassistant.core import HomeAssistant, split_entity_id from homeassistant.helpers import entity_registry as er from homeassistant.helpers.entity import EntityDescription -from homeassistant.setup import async_setup_component from homeassistant.util import dt as dt_util from tests.common import MockConfigEntry, async_fire_time_changed @@ -167,7 +166,6 @@ async def init_entry( ufp: MockUFPFixture, devices: Sequence[ProtectAdoptableDeviceModel], regenerate_ids: bool = True, - debug: bool = False, ) -> None: """Initialize Protect entry with given devices.""" @@ -175,14 +173,6 @@ async def init_entry( for device in devices: add_device(ufp.api.bootstrap, device, regenerate_ids) - if debug: - assert await async_setup_component(hass, "logger", {"logger": {}}) - await hass.services.async_call( - "logger", - "set_level", - {"homeassistant.components.unifiprotect": "DEBUG"}, - blocking=True, - ) await hass.config_entries.async_setup(ufp.entry.entry_id) await hass.async_block_till_done() From 5843e63878cb64a4c83202ffd2242fba5726efe3 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Sun, 20 Apr 2025 02:13:01 +0200 Subject: [PATCH 18/70] Add contextmanager to reset logger after set_level call in tests (#143295) --- tests/common.py | 23 ++++ tests/components/bluetooth/test_manager.py | 84 ++++++------ tests/components/bluetooth/test_scanner.py | 125 +++++++++--------- tests/components/esphome/test_manager.py | 134 +++++++++---------- tests/components/http/test_init.py | 36 +++--- tests/components/logger/test_init.py | 43 +++---- tests/components/websocket_api/test_http.py | 42 +++--- tests/components/zwave_js/test_init.py | 135 ++++++++++---------- 8 files changed, 304 insertions(+), 318 deletions(-) diff --git a/tests/common.py b/tests/common.py index f426d2aebd2..8f06aa54383 100644 --- a/tests/common.py +++ b/tests/common.py @@ -46,6 +46,7 @@ from homeassistant.components import device_automation, persistent_notification from homeassistant.components.device_automation import ( # noqa: F401 _async_get_device_automation_capabilities as async_get_device_automation_capabilities, ) +from homeassistant.components.logger import DOMAIN as LOGGER_DOMAIN, SERVICE_SET_LEVEL from homeassistant.config import IntegrationConfigInfo, async_process_component_config from homeassistant.config_entries import ConfigEntry, ConfigFlow, ConfigFlowResult from homeassistant.const import ( @@ -1688,6 +1689,28 @@ def async_mock_cloud_connection_status(hass: HomeAssistant, connected: bool) -> async_dispatcher_send(hass, SIGNAL_CLOUD_CONNECTION_STATE, state) +@asynccontextmanager +async def async_call_logger_set_level( + logger: str, + level: Literal["DEBUG", "INFO", "WARNING", "ERROR", "FATAL", "CRITICAL"], + *, + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, +) -> AsyncGenerator[None]: + """Context manager to reset loggers after logger.set_level call.""" + assert LOGGER_DOMAIN in hass.data, "'logger' integration not setup" + with caplog.at_level(logging.NOTSET, logger): + await hass.services.async_call( + LOGGER_DOMAIN, + SERVICE_SET_LEVEL, + {logger: level}, + blocking=True, + ) + await hass.async_block_till_done() + yield + hass.data[LOGGER_DOMAIN].overrides.clear() + + def import_and_test_deprecated_constant_enum( caplog: pytest.LogCaptureFixture, module: ModuleType, diff --git a/tests/components/bluetooth/test_manager.py b/tests/components/bluetooth/test_manager.py index 48d1a38375d..bf773b69a99 100644 --- a/tests/components/bluetooth/test_manager.py +++ b/tests/components/bluetooth/test_manager.py @@ -61,6 +61,7 @@ from . import ( from tests.common import ( MockConfigEntry, MockModule, + async_call_logger_set_level, async_fire_time_changed, load_fixture, mock_integration, @@ -1144,54 +1145,45 @@ async def test_debug_logging( ) -> None: """Test debug logging.""" assert await async_setup_component(hass, "logger", {"logger": {}}) - await hass.services.async_call( - "logger", - "set_level", - {"homeassistant.components.bluetooth": "DEBUG"}, - blocking=True, - ) - await hass.async_block_till_done() + async with async_call_logger_set_level( + "homeassistant.components.bluetooth", "DEBUG", hass=hass, caplog=caplog + ): + address = "44:44:33:11:23:41" + start_time_monotonic = 50.0 - address = "44:44:33:11:23:41" - start_time_monotonic = 50.0 + switchbot_device_poor_signal_hci0 = generate_ble_device( + address, "wohand_poor_signal_hci0" + ) + switchbot_adv_poor_signal_hci0 = generate_advertisement_data( + local_name="wohand_poor_signal_hci0", service_uuids=[], rssi=-100 + ) + inject_advertisement_with_time_and_source( + hass, + switchbot_device_poor_signal_hci0, + switchbot_adv_poor_signal_hci0, + start_time_monotonic, + "hci0", + ) + assert "wohand_poor_signal_hci0" in caplog.text + caplog.clear() - switchbot_device_poor_signal_hci0 = generate_ble_device( - address, "wohand_poor_signal_hci0" - ) - switchbot_adv_poor_signal_hci0 = generate_advertisement_data( - local_name="wohand_poor_signal_hci0", service_uuids=[], rssi=-100 - ) - inject_advertisement_with_time_and_source( - hass, - switchbot_device_poor_signal_hci0, - switchbot_adv_poor_signal_hci0, - start_time_monotonic, - "hci0", - ) - assert "wohand_poor_signal_hci0" in caplog.text - caplog.clear() - - await hass.services.async_call( - "logger", - "set_level", - {"homeassistant.components.bluetooth": "WARNING"}, - blocking=True, - ) - - switchbot_device_good_signal_hci0 = generate_ble_device( - address, "wohand_good_signal_hci0" - ) - switchbot_adv_good_signal_hci0 = generate_advertisement_data( - local_name="wohand_good_signal_hci0", service_uuids=[], rssi=-33 - ) - inject_advertisement_with_time_and_source( - hass, - switchbot_device_good_signal_hci0, - switchbot_adv_good_signal_hci0, - start_time_monotonic, - "hci0", - ) - assert "wohand_good_signal_hci0" not in caplog.text + async with async_call_logger_set_level( + "homeassistant.components.bluetooth", "WARNING", hass=hass, caplog=caplog + ): + switchbot_device_good_signal_hci0 = generate_ble_device( + address, "wohand_good_signal_hci0" + ) + switchbot_adv_good_signal_hci0 = generate_advertisement_data( + local_name="wohand_good_signal_hci0", service_uuids=[], rssi=-33 + ) + inject_advertisement_with_time_and_source( + hass, + switchbot_device_good_signal_hci0, + switchbot_adv_good_signal_hci0, + start_time_monotonic, + "hci0", + ) + assert "wohand_good_signal_hci0" not in caplog.text @pytest.mark.usefixtures("enable_bluetooth", "macos_adapter") diff --git a/tests/components/bluetooth/test_scanner.py b/tests/components/bluetooth/test_scanner.py index 6acb86476e7..142438fbb95 100644 --- a/tests/components/bluetooth/test_scanner.py +++ b/tests/components/bluetooth/test_scanner.py @@ -29,7 +29,11 @@ from . import ( patch_bluetooth_time, ) -from tests.common import MockConfigEntry, async_fire_time_changed +from tests.common import ( + MockConfigEntry, + async_call_logger_set_level, + async_fire_time_changed, +) # If the adapter is in a stuck state the following errors are raised: NEED_RESET_ERRORS = [ @@ -482,70 +486,67 @@ async def test_adapter_fails_to_start_and_takes_a_bit_to_init( ) -> None: """Test we can recover the adapter at startup and we wait for Dbus to init.""" assert await async_setup_component(hass, "logger", {}) - await hass.services.async_call( - "logger", - "set_level", - {"homeassistant.components.bluetooth": "DEBUG"}, - blocking=True, - ) - called_start = 0 - called_stop = 0 - _callback = None - mock_discovered = [] - - class MockBleakScanner: - async def start(self, *args, **kwargs): - """Mock Start.""" - nonlocal called_start - called_start += 1 - if called_start == 1: - raise BleakError("org.freedesktop.DBus.Error.UnknownObject") - if called_start == 2: - raise BleakError("org.bluez.Error.InProgress") - if called_start == 3: - raise BleakError("org.bluez.Error.InProgress") - - async def stop(self, *args, **kwargs): - """Mock Start.""" - nonlocal called_stop - called_stop += 1 - - @property - def discovered_devices(self): - """Mock discovered_devices.""" - nonlocal mock_discovered - return mock_discovered - - def register_detection_callback(self, callback: AdvertisementDataCallback): - """Mock Register Detection Callback.""" - nonlocal _callback - _callback = callback - - scanner = MockBleakScanner() - start_time_monotonic = time.monotonic() - - with ( - patch( - "habluetooth.scanner.ADAPTER_INIT_TIME", - 0, - ), - patch_bluetooth_time( - start_time_monotonic, - ), - patch( - "habluetooth.scanner.OriginalBleakScanner", - return_value=scanner, - ), - patch( - "habluetooth.util.recover_adapter", return_value=True - ) as mock_recover_adapter, + async with async_call_logger_set_level( + "homeassistant.components.bluetooth", "DEBUG", hass=hass, caplog=caplog ): - await async_setup_with_one_adapter(hass) + called_start = 0 + called_stop = 0 + _callback = None + mock_discovered = [] - assert called_start == 4 + class MockBleakScanner: + async def start(self, *args, **kwargs): + """Mock Start.""" + nonlocal called_start + called_start += 1 + if called_start == 1: + raise BleakError("org.freedesktop.DBus.Error.UnknownObject") + if called_start == 2: + raise BleakError("org.bluez.Error.InProgress") + if called_start == 3: + raise BleakError("org.bluez.Error.InProgress") - assert len(mock_recover_adapter.mock_calls) == 1 - assert "Waiting for adapter to initialize" in caplog.text + async def stop(self, *args, **kwargs): + """Mock Start.""" + nonlocal called_stop + called_stop += 1 + + @property + def discovered_devices(self): + """Mock discovered_devices.""" + nonlocal mock_discovered + return mock_discovered + + def register_detection_callback(self, callback: AdvertisementDataCallback): + """Mock Register Detection Callback.""" + nonlocal _callback + _callback = callback + + scanner = MockBleakScanner() + start_time_monotonic = time.monotonic() + + with ( + patch( + "habluetooth.scanner.ADAPTER_INIT_TIME", + 0, + ), + patch_bluetooth_time( + start_time_monotonic, + ), + patch( + "habluetooth.scanner.OriginalBleakScanner", + return_value=scanner, + ), + patch( + "habluetooth.util.recover_adapter", return_value=True + ) as mock_recover_adapter, + ): + await async_setup_with_one_adapter(hass) + + assert called_start == 4 + + assert len(mock_recover_adapter.mock_calls) == 1 + assert "Waiting for adapter to initialize" in caplog.text @pytest.mark.usefixtures("one_adapter") diff --git a/tests/components/esphome/test_manager.py b/tests/components/esphome/test_manager.py index 9e7810cde8f..12ae58a8240 100644 --- a/tests/components/esphome/test_manager.py +++ b/tests/components/esphome/test_manager.py @@ -49,7 +49,12 @@ from homeassistant.setup import async_setup_component from .conftest import MockESPHomeDevice -from tests.common import MockConfigEntry, async_capture_events, async_mock_service +from tests.common import ( + MockConfigEntry, + async_call_logger_set_level, + async_capture_events, + async_mock_service, +) async def test_esphome_device_subscribe_logs( @@ -83,62 +88,50 @@ async def test_esphome_device_subscribe_logs( ) await hass.async_block_till_done() - await hass.services.async_call( - "logger", - "set_level", - {"homeassistant.components.esphome": "DEBUG"}, - blocking=True, - ) - assert device.current_log_level == LogLevel.LOG_LEVEL_VERY_VERBOSE + async with async_call_logger_set_level( + "homeassistant.components.esphome", "DEBUG", hass=hass, caplog=caplog + ): + assert device.current_log_level == LogLevel.LOG_LEVEL_VERY_VERBOSE - caplog.set_level(logging.DEBUG) - device.mock_on_log_message( - Mock(level=LogLevel.LOG_LEVEL_INFO, message=b"test_log_message") - ) - await hass.async_block_till_done() - assert "test_log_message" in caplog.text + caplog.set_level(logging.DEBUG) + device.mock_on_log_message( + Mock(level=LogLevel.LOG_LEVEL_INFO, message=b"test_log_message") + ) + await hass.async_block_till_done() + assert "test_log_message" in caplog.text - device.mock_on_log_message( - Mock(level=LogLevel.LOG_LEVEL_ERROR, message=b"test_error_log_message") - ) - await hass.async_block_till_done() - assert "test_error_log_message" in caplog.text + device.mock_on_log_message( + Mock(level=LogLevel.LOG_LEVEL_ERROR, message=b"test_error_log_message") + ) + await hass.async_block_till_done() + assert "test_error_log_message" in caplog.text - caplog.set_level(logging.ERROR) - device.mock_on_log_message( - Mock(level=LogLevel.LOG_LEVEL_DEBUG, message=b"test_debug_log_message") - ) - await hass.async_block_till_done() - assert "test_debug_log_message" not in caplog.text + caplog.set_level(logging.ERROR) + device.mock_on_log_message( + Mock(level=LogLevel.LOG_LEVEL_DEBUG, message=b"test_debug_log_message") + ) + await hass.async_block_till_done() + assert "test_debug_log_message" not in caplog.text - caplog.set_level(logging.DEBUG) - device.mock_on_log_message( - Mock(level=LogLevel.LOG_LEVEL_DEBUG, message=b"test_debug_log_message") - ) - await hass.async_block_till_done() - assert "test_debug_log_message" in caplog.text + caplog.set_level(logging.DEBUG) + device.mock_on_log_message( + Mock(level=LogLevel.LOG_LEVEL_DEBUG, message=b"test_debug_log_message") + ) + await hass.async_block_till_done() + assert "test_debug_log_message" in caplog.text - await hass.services.async_call( - "logger", - "set_level", - {"homeassistant.components.esphome": "WARNING"}, - blocking=True, - ) - assert device.current_log_level == LogLevel.LOG_LEVEL_WARN - await hass.services.async_call( - "logger", - "set_level", - {"homeassistant.components.esphome": "ERROR"}, - blocking=True, - ) - assert device.current_log_level == LogLevel.LOG_LEVEL_ERROR - await hass.services.async_call( - "logger", - "set_level", - {"homeassistant.components.esphome": "INFO"}, - blocking=True, - ) - assert device.current_log_level == LogLevel.LOG_LEVEL_CONFIG + async with async_call_logger_set_level( + "homeassistant.components.esphome", "WARNING", hass=hass, caplog=caplog + ): + assert device.current_log_level == LogLevel.LOG_LEVEL_WARN + async with async_call_logger_set_level( + "homeassistant.components.esphome", "ERROR", hass=hass, caplog=caplog + ): + assert device.current_log_level == LogLevel.LOG_LEVEL_ERROR + async with async_call_logger_set_level( + "homeassistant.components.esphome", "INFO", hass=hass, caplog=caplog + ): + assert device.current_log_level == LogLevel.LOG_LEVEL_CONFIG async def test_esphome_device_service_calls_not_allowed( @@ -961,31 +954,22 @@ async def test_debug_logging( caplog: pytest.LogCaptureFixture, ) -> None: """Test enabling and disabling debug logging.""" - with caplog.at_level(logging.NOTSET, "homeassistant.components.esphome"): - assert await async_setup_component(hass, "logger", {"logger": {}}) - await mock_generic_device_entry( - mock_client=mock_client, - entity_info=[], - user_service=[], - states=[], - ) - await hass.services.async_call( - "logger", - "set_level", - {"homeassistant.components.esphome": "DEBUG"}, - blocking=True, - ) - await hass.async_block_till_done() + assert await async_setup_component(hass, "logger", {"logger": {}}) + await mock_generic_device_entry( + mock_client=mock_client, + entity_info=[], + user_service=[], + states=[], + ) + async with async_call_logger_set_level( + "homeassistant.components.esphome", "DEBUG", hass=hass, caplog=caplog + ): mock_client.set_debug.assert_has_calls([call(True)]) - mock_client.reset_mock() - await hass.services.async_call( - "logger", - "set_level", - {"homeassistant.components.esphome": "WARNING"}, - blocking=True, - ) - await hass.async_block_till_done() + + async with async_call_logger_set_level( + "homeassistant.components.esphome", "WARNING", hass=hass, caplog=caplog + ): mock_client.set_debug.assert_has_calls([call(False)]) diff --git a/tests/components/http/test_init.py b/tests/components/http/test_init.py index 4d96f2267fa..2937e673946 100644 --- a/tests/components/http/test_init.py +++ b/tests/components/http/test_init.py @@ -22,7 +22,7 @@ from homeassistant.setup import async_setup_component from homeassistant.util import dt as dt_util from homeassistant.util.ssl import server_context_intermediate, server_context_modern -from tests.common import async_fire_time_changed +from tests.common import async_call_logger_set_level, async_fire_time_changed from tests.typing import ClientSessionGenerator @@ -505,27 +505,21 @@ async def test_logging( ) ) hass.states.async_set("logging.entity", "hello") - await hass.services.async_call( - "logger", - "set_level", - {"aiohttp.access": "info"}, - blocking=True, - ) - client = await hass_client() - response = await client.get("/api/states/logging.entity") - assert response.status == HTTPStatus.OK + async with async_call_logger_set_level( + "aiohttp.access", "INFO", hass=hass, caplog=caplog + ): + client = await hass_client() + response = await client.get("/api/states/logging.entity") + assert response.status == HTTPStatus.OK - assert "GET /api/states/logging.entity" in caplog.text - caplog.clear() - await hass.services.async_call( - "logger", - "set_level", - {"aiohttp.access": "warning"}, - blocking=True, - ) - response = await client.get("/api/states/logging.entity") - assert response.status == HTTPStatus.OK - assert "GET /api/states/logging.entity" not in caplog.text + assert "GET /api/states/logging.entity" in caplog.text + caplog.clear() + async with async_call_logger_set_level( + "aiohttp.access", "WARNING", hass=hass, caplog=caplog + ): + response = await client.get("/api/states/logging.entity") + assert response.status == HTTPStatus.OK + assert "GET /api/states/logging.entity" not in caplog.text async def test_register_static_paths( diff --git a/tests/components/logger/test_init.py b/tests/components/logger/test_init.py index 24e58a77226..53b8b72b385 100644 --- a/tests/components/logger/test_init.py +++ b/tests/components/logger/test_init.py @@ -15,7 +15,7 @@ from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component from homeassistant.util import dt as dt_util -from tests.common import async_fire_time_changed +from tests.common import async_call_logger_set_level, async_fire_time_changed HASS_NS = "unused.homeassistant" COMPONENTS_NS = f"{HASS_NS}.components" @@ -73,28 +73,27 @@ async def test_log_filtering( msg_test(filter_logger, True, "format string shouldfilter%s", "not") # Filtering should work even if log level is modified - await hass.services.async_call( - "logger", - "set_level", - {"test.filter": "warning"}, - blocking=True, - ) - assert filter_logger.getEffectiveLevel() == logging.WARNING - msg_test( - filter_logger, - False, - "this line containing shouldfilterall should still be filtered", - ) + async with async_call_logger_set_level( + "test.filter", "WARNING", hass=hass, caplog=caplog + ): + assert filter_logger.getEffectiveLevel() == logging.WARNING + msg_test( + filter_logger, + False, + "this line containing shouldfilterall should still be filtered", + ) - # Filtering should be scoped to a service - msg_test( - filter_logger, True, "this line containing otherfilterer should not be filtered" - ) - msg_test( - logging.getLogger("test.other_filter"), - False, - "this line containing otherfilterer SHOULD be filtered", - ) + # Filtering should be scoped to a service + msg_test( + filter_logger, + True, + "this line containing otherfilterer should not be filtered", + ) + msg_test( + logging.getLogger("test.other_filter"), + False, + "this line containing otherfilterer SHOULD be filtered", + ) async def test_setting_level(hass: HomeAssistant) -> None: diff --git a/tests/components/websocket_api/test_http.py b/tests/components/websocket_api/test_http.py index 075f5fa9c0a..b4b11d9cf02 100644 --- a/tests/components/websocket_api/test_http.py +++ b/tests/components/websocket_api/test_http.py @@ -19,7 +19,7 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.setup import async_setup_component from homeassistant.util.dt import utcnow -from tests.common import async_fire_time_changed +from tests.common import async_call_logger_set_level, async_fire_time_changed from tests.typing import MockHAClientWebSocket, WebSocketGenerator @@ -533,27 +533,19 @@ async def test_enable_disable_debug_logging( ) -> None: """Test enabling and disabling debug logging.""" assert await async_setup_component(hass, "logger", {"logger": {}}) - await hass.services.async_call( - "logger", - "set_level", - {"homeassistant.components.websocket_api": "DEBUG"}, - blocking=True, - ) - await hass.async_block_till_done() - await websocket_client.send_json({"id": 1, "type": "ping"}) - msg = await websocket_client.receive_json() - assert msg["id"] == 1 - assert msg["type"] == "pong" - assert 'Sending b\'{"id":1,"type":"pong"}\'' in caplog.text - await hass.services.async_call( - "logger", - "set_level", - {"homeassistant.components.websocket_api": "WARNING"}, - blocking=True, - ) - await hass.async_block_till_done() - await websocket_client.send_json({"id": 2, "type": "ping"}) - msg = await websocket_client.receive_json() - assert msg["id"] == 2 - assert msg["type"] == "pong" - assert 'Sending b\'{"id":2,"type":"pong"}\'' not in caplog.text + async with async_call_logger_set_level( + "homeassistant.components.websocket_api", "DEBUG", hass=hass, caplog=caplog + ): + await websocket_client.send_json({"id": 1, "type": "ping"}) + msg = await websocket_client.receive_json() + assert msg["id"] == 1 + assert msg["type"] == "pong" + assert 'Sending b\'{"id":1,"type":"pong"}\'' in caplog.text + async with async_call_logger_set_level( + "homeassistant.components.websocket_api", "WARNING", hass=hass, caplog=caplog + ): + await websocket_client.send_json({"id": 2, "type": "ping"}) + msg = await websocket_client.receive_json() + assert msg["id"] == 2 + assert msg["type"] == "pong" + assert 'Sending b\'{"id":2,"type":"pong"}\'' not in caplog.text diff --git a/tests/components/zwave_js/test_init.py b/tests/components/zwave_js/test_init.py index 4abda90b5cf..a0423efdf52 100644 --- a/tests/components/zwave_js/test_init.py +++ b/tests/components/zwave_js/test_init.py @@ -23,7 +23,6 @@ from zwave_js_server.model.node import Node, NodeDataType from zwave_js_server.model.version import VersionInfo from homeassistant.components.hassio import HassioAPIError -from homeassistant.components.logger import DOMAIN as LOGGER_DOMAIN, SERVICE_SET_LEVEL from homeassistant.components.persistent_notification import async_dismiss from homeassistant.components.zwave_js import DOMAIN from homeassistant.components.zwave_js.helpers import get_device_id, get_device_id_ext @@ -42,6 +41,7 @@ from .common import AIR_TEMPERATURE_SENSOR, EATON_RF9640_ENTITY from tests.common import ( MockConfigEntry, + async_call_logger_set_level, async_fire_time_changed, async_get_persistent_notifications, ) @@ -2018,7 +2018,9 @@ async def test_identify_event( assert "network with the home ID `3245146787`" in notifications[msg_id]["message"] -async def test_server_logging(hass: HomeAssistant, client: MagicMock) -> None: +async def test_server_logging( + hass: HomeAssistant, client: MagicMock, caplog: pytest.LogCaptureFixture +) -> None: """Test automatic server logging functionality.""" def _reset_mocks(): @@ -2037,83 +2039,82 @@ async def test_server_logging(hass: HomeAssistant, client: MagicMock) -> None: # Setup logger and set log level to debug to trigger event listener assert await async_setup_component(hass, "logger", {"logger": {}}) - assert logging.getLogger("zwave_js_server").getEffectiveLevel() == logging.INFO - client.async_send_command.reset_mock() - await hass.services.async_call( - LOGGER_DOMAIN, SERVICE_SET_LEVEL, {"zwave_js_server": "debug"}, blocking=True - ) - await hass.async_block_till_done() assert logging.getLogger("zwave_js_server").getEffectiveLevel() == logging.DEBUG + client.async_send_command.reset_mock() + async with async_call_logger_set_level( + "zwave_js_server", "DEBUG", hass=hass, caplog=caplog + ): + assert logging.getLogger("zwave_js_server").getEffectiveLevel() == logging.DEBUG - # Validate that the server logging was enabled - assert len(client.async_send_command.call_args_list) == 1 - assert client.async_send_command.call_args[0][0] == { - "command": "driver.update_log_config", - "config": {"level": "debug"}, - } - assert client.enable_server_logging.called - assert not client.disable_server_logging.called + # Validate that the server logging was enabled + assert len(client.async_send_command.call_args_list) == 1 + assert client.async_send_command.call_args[0][0] == { + "command": "driver.update_log_config", + "config": {"level": "debug"}, + } + assert client.enable_server_logging.called + assert not client.disable_server_logging.called - _reset_mocks() + _reset_mocks() - # Emulate server by setting log level to debug - event = Event( - type="log config updated", - data={ - "source": "driver", - "event": "log config updated", - "config": { - "enabled": False, - "level": "debug", - "logToFile": True, - "filename": "test", - "forceConsole": True, + # Emulate server by setting log level to debug + event = Event( + type="log config updated", + data={ + "source": "driver", + "event": "log config updated", + "config": { + "enabled": False, + "level": "debug", + "logToFile": True, + "filename": "test", + "forceConsole": True, + }, }, - }, - ) - client.driver.receive_event(event) + ) + client.driver.receive_event(event) - # "Enable" server logging and unload the entry - client.server_logging_enabled = True - await hass.config_entries.async_unload(entry.entry_id) + # "Enable" server logging and unload the entry + client.server_logging_enabled = True + await hass.config_entries.async_unload(entry.entry_id) - # Validate that the server logging was disabled - assert len(client.async_send_command.call_args_list) == 1 - assert client.async_send_command.call_args[0][0] == { - "command": "driver.update_log_config", - "config": {"level": "info"}, - } - assert not client.enable_server_logging.called - assert client.disable_server_logging.called + # Validate that the server logging was disabled + assert len(client.async_send_command.call_args_list) == 1 + assert client.async_send_command.call_args[0][0] == { + "command": "driver.update_log_config", + "config": {"level": "info"}, + } + assert not client.enable_server_logging.called + assert client.disable_server_logging.called - _reset_mocks() + _reset_mocks() - # Validate that the server logging doesn't get enabled because HA thinks it already - # is enabled - await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() - assert len(client.async_send_command.call_args_list) == 2 - assert client.async_send_command.call_args_list[0][0][0] == { - "command": "controller.get_provisioning_entries", - } - assert client.async_send_command.call_args_list[1][0][0] == { - "command": "controller.get_provisioning_entry", - "dskOrNodeId": 1, - } - assert not client.enable_server_logging.called - assert not client.disable_server_logging.called + # Validate that the server logging doesn't get enabled because HA thinks it already + # is enabled + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + assert len(client.async_send_command.call_args_list) == 2 + assert client.async_send_command.call_args_list[0][0][0] == { + "command": "controller.get_provisioning_entries", + } + assert client.async_send_command.call_args_list[1][0][0] == { + "command": "controller.get_provisioning_entry", + "dskOrNodeId": 1, + } + assert not client.enable_server_logging.called + assert not client.disable_server_logging.called - _reset_mocks() + _reset_mocks() - # "Disable" server logging and unload the entry - client.server_logging_enabled = False - await hass.config_entries.async_unload(entry.entry_id) + # "Disable" server logging and unload the entry + client.server_logging_enabled = False + await hass.config_entries.async_unload(entry.entry_id) - # Validate that the server logging was not disabled because HA thinks it is already - # is disabled - assert len(client.async_send_command.call_args_list) == 0 - assert not client.enable_server_logging.called - assert not client.disable_server_logging.called + # Validate that the server logging was not disabled because HA thinks it is already + # is disabled + assert len(client.async_send_command.call_args_list) == 0 + assert not client.enable_server_logging.called + assert not client.disable_server_logging.called async def test_factory_reset_node( From f861a2b72cbc278ee8ba2bac8560f105aeea15c8 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Sun, 20 Apr 2025 02:24:44 +0200 Subject: [PATCH 19/70] Fix licenses check for setuptools (#143292) --- script/licenses.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/script/licenses.py b/script/licenses.py index ab8ab62eb1d..aed3bec9998 100644 --- a/script/licenses.py +++ b/script/licenses.py @@ -191,7 +191,6 @@ EXCEPTIONS = { "enocean", # https://github.com/kipe/enocean/pull/142 "imutils", # https://github.com/PyImageSearch/imutils/pull/292 "iso4217", # Public domain - "jaraco.itertools", # MIT - https://github.com/jaraco/jaraco.itertools/issues/21 "kiwiki_client", # https://github.com/c7h/kiwiki_client/pull/6 "ld2410-ble", # https://github.com/930913/ld2410-ble/pull/7 "maxcube-api", # https://github.com/uebelack/python-maxcube-api/pull/48 @@ -205,6 +204,11 @@ EXCEPTIONS = { "repoze.lru", "sharp_aquos_rc", # https://github.com/jmoore987/sharp_aquos_rc/pull/14 "tapsaff", # https://github.com/bazwilliams/python-taps-aff/pull/5 + # --- + # https://github.com/jaraco/skeleton/pull/170 + # https://github.com/jaraco/skeleton/pull/171 + "jaraco.itertools", # MIT - https://github.com/jaraco/jaraco.itertools/issues/21 + "setuptools", # MIT } TODO = { From 205cfae1a4468282220fbb3cb77117bf62c90ace Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Sun, 20 Apr 2025 03:57:53 +0200 Subject: [PATCH 20/70] Update setuptools to 78.1.1 (#143275) --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 7b3168daec0..7ec4b80f019 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,5 +1,5 @@ [build-system] -requires = ["setuptools==77.0.3"] +requires = ["setuptools==78.1.1"] build-backend = "setuptools.build_meta" [project] From 7c0d2832cde0069359ef0d121bd6ac08459c6da7 Mon Sep 17 00:00:00 2001 From: tdfountain <174762217+tdfountain@users.noreply.github.com> Date: Sat, 19 Apr 2025 19:07:42 -0700 Subject: [PATCH 21/70] Add remove device support to NUT (#143293) --- homeassistant/components/nut/__init__.py | 14 +++++ tests/components/nut/test_init.py | 74 ++++++++++++++++++++++++ 2 files changed, 88 insertions(+) diff --git a/homeassistant/components/nut/__init__.py b/homeassistant/components/nut/__init__.py index 9e1e77a2aaf..ae20ed39251 100644 --- a/homeassistant/components/nut/__init__.py +++ b/homeassistant/components/nut/__init__.py @@ -185,6 +185,20 @@ async def async_unload_entry(hass: HomeAssistant, entry: NutConfigEntry) -> bool return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) +async def async_remove_config_entry_device( + hass: HomeAssistant, + config_entry: ConfigEntry, + device_entry: dr.DeviceEntry, +) -> bool: + """Remove NUT config entry from a device.""" + return not any( + identifier + for identifier in device_entry.identifiers + if identifier[0] == DOMAIN + and identifier[1] in config_entry.runtime_data.unique_id + ) + + async def _async_update_listener(hass: HomeAssistant, entry: NutConfigEntry) -> None: """Handle options update.""" await hass.config_entries.async_reload(entry.entry_id) diff --git a/tests/components/nut/test_init.py b/tests/components/nut/test_init.py index b3cf23bddcc..8b3799caade 100644 --- a/tests/components/nut/test_init.py +++ b/tests/components/nut/test_init.py @@ -18,10 +18,12 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr +from homeassistant.setup import async_setup_component from .util import _get_mock_nutclient, async_init_integration from tests.common import MockConfigEntry +from tests.typing import WebSocketGenerator async def test_config_entry_migrations(hass: HomeAssistant) -> None: @@ -84,6 +86,78 @@ async def test_async_setup_entry(hass: HomeAssistant) -> None: assert not hass.data.get(DOMAIN) +async def test_remove_device_valid( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + device_registry: dr.DeviceRegistry, +) -> None: + """Test that we cannot remove a device that still exists.""" + assert await async_setup_component(hass, "config", {}) + + mock_serial_number = "A00000000000" + config_entry = await async_init_integration( + hass, + username="someuser", + password="somepassword", + list_vars={"ups.serial": mock_serial_number}, + list_ups={"ups1": "UPS 1"}, + list_commands_return_value=[], + ) + + device_registry = dr.async_get(hass) + assert device_registry is not None + + device_entry = device_registry.async_get_device( + identifiers={(DOMAIN, mock_serial_number)} + ) + + assert device_entry is not None + assert device_entry.serial_number == mock_serial_number + + client = await hass_ws_client(hass) + response = await client.remove_device(device_entry.id, config_entry.entry_id) + assert not response["success"] + + +async def test_remove_device_stale( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + device_registry: dr.DeviceRegistry, +) -> None: + """Test that we can remove a device that no longer exists.""" + assert await async_setup_component(hass, "config", {}) + + mock_serial_number = "A00000000000" + config_entry = await async_init_integration( + hass, + username="someuser", + password="somepassword", + list_vars={"ups.serial": mock_serial_number}, + list_ups={"ups1": "UPS 1"}, + list_commands_return_value=[], + ) + + device_registry = dr.async_get(hass) + assert device_registry is not None + + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + identifiers={(DOMAIN, "remove-device-id")}, + ) + + assert device_entry is not None + + client = await hass_ws_client(hass) + response = await client.remove_device(device_entry.id, config_entry.entry_id) + assert response["success"] + + # Verify that device entry is removed + device_entry = device_registry.async_get_device( + identifiers={(DOMAIN, "remove-device-id")} + ) + assert device_entry is None + + async def test_config_not_ready( hass: HomeAssistant, caplog: pytest.LogCaptureFixture, From b97d8e163d5c0df8f5caeba46d4e71c5e695a063 Mon Sep 17 00:00:00 2001 From: tdfountain <174762217+tdfountain@users.noreply.github.com> Date: Sat, 19 Apr 2025 19:13:05 -0700 Subject: [PATCH 22/70] Fix type of port in test util for NUT (#143303) --- tests/components/nut/util.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/components/nut/util.py b/tests/components/nut/util.py index 889fdc327af..a4415612286 100644 --- a/tests/components/nut/util.py +++ b/tests/components/nut/util.py @@ -43,7 +43,7 @@ async def async_init_integration( hass: HomeAssistant, ups_fixture: str | None = None, host: str = "mock", - port: str = "mock", + port: int = 1234, username: str = "mock", password: str = "mock", alias: str | None = None, From eb642e8a06fedeccbba528490144739a14a1a032 Mon Sep 17 00:00:00 2001 From: tdfountain <174762217+tdfountain@users.noreply.github.com> Date: Sat, 19 Apr 2025 19:22:10 -0700 Subject: [PATCH 23/70] Remove unused variable in test util for NUT (#143304) --- tests/components/nut/test_sensor.py | 5 ----- tests/components/nut/util.py | 1 - 2 files changed, 6 deletions(-) diff --git a/tests/components/nut/test_sensor.py b/tests/components/nut/test_sensor.py index 89f06c934f8..db9028222b1 100644 --- a/tests/components/nut/test_sensor.py +++ b/tests/components/nut/test_sensor.py @@ -125,7 +125,6 @@ async def test_pdu_devices_with_unique_ids( _test_sensor_and_attributes( hass, entity_registry, - model, unique_id=f"{unique_id_base}_input.voltage", device_id="sensor.ups1_input_voltage", state_value="122.91", @@ -140,7 +139,6 @@ async def test_pdu_devices_with_unique_ids( _test_sensor_and_attributes( hass, entity_registry, - model, unique_id=f"{unique_id_base}_ambient.humidity.status", device_id="sensor.ups1_ambient_humidity_status", state_value="good", @@ -153,7 +151,6 @@ async def test_pdu_devices_with_unique_ids( _test_sensor_and_attributes( hass, entity_registry, - model, unique_id=f"{unique_id_base}_ambient.temperature.status", device_id="sensor.ups1_ambient_temperature_status", state_value="good", @@ -334,7 +331,6 @@ async def test_pdu_dynamic_outlets( _test_sensor_and_attributes( hass, entity_registry, - model, unique_id=f"{unique_id_base}_outlet.1.current", device_id="sensor.ups1_outlet_a1_current", state_value="0", @@ -348,7 +344,6 @@ async def test_pdu_dynamic_outlets( _test_sensor_and_attributes( hass, entity_registry, - model, unique_id=f"{unique_id_base}_outlet.24.current", device_id="sensor.ups1_outlet_a24_current", state_value="0.19", diff --git a/tests/components/nut/util.py b/tests/components/nut/util.py index a4415612286..49510fc9d72 100644 --- a/tests/components/nut/util.py +++ b/tests/components/nut/util.py @@ -104,7 +104,6 @@ async def async_init_integration( def _test_sensor_and_attributes( hass: HomeAssistant, entity_registry: er.EntityRegistry, - model: str, unique_id: str, device_id: str, state_value: str, From cbb4ff2fd928e330423af7b16783eee233b747dd Mon Sep 17 00:00:00 2001 From: tdfountain <174762217+tdfountain@users.noreply.github.com> Date: Sat, 19 Apr 2025 19:35:13 -0700 Subject: [PATCH 24/70] Remove icon for button that uses default icon in NUT (#143305) --- homeassistant/components/nut/icons.json | 5 ----- 1 file changed, 5 deletions(-) diff --git a/homeassistant/components/nut/icons.json b/homeassistant/components/nut/icons.json index a795368005c..ae87c955164 100644 --- a/homeassistant/components/nut/icons.json +++ b/homeassistant/components/nut/icons.json @@ -1,10 +1,5 @@ { "entity": { - "button": { - "outlet_number_load_cycle": { - "default": "mdi:restart" - } - }, "sensor": { "ambient_humidity_status": { "default": "mdi:information-outline" From 6b09fe2377aa58d5387a909560a2c583d76366ee Mon Sep 17 00:00:00 2001 From: Mick Vleeshouwer Date: Sun, 20 Apr 2025 06:29:18 +0200 Subject: [PATCH 25/70] Support new local token generation method in Overkiz (#143181) * Initial implementation of new token method for Local API * Improve translations * Update text * Bugfix * Bugfix * Bugfixes * Fixes * Bugfix * Bugfix * Fix * small fix * Fix tests * Refactor token usage in Overkiz config flow tests * Refactor local API configuration flow tests for clarity and update reauthentication logic * Improve comments * Update tests * Update homeassistant/components/overkiz/strings.json Co-authored-by: Josef Zweck --------- Co-authored-by: Josef Zweck --- homeassistant/components/overkiz/__init__.py | 7 +- .../components/overkiz/config_flow.py | 125 ++++------- .../components/overkiz/coordinator.py | 4 +- homeassistant/components/overkiz/strings.json | 8 +- tests/components/overkiz/test_config_flow.py | 202 +++++++++--------- 5 files changed, 153 insertions(+), 193 deletions(-) diff --git a/homeassistant/components/overkiz/__init__.py b/homeassistant/components/overkiz/__init__.py index 8aa1ed0e4fe..c9bf618ee8f 100644 --- a/homeassistant/components/overkiz/__init__.py +++ b/homeassistant/components/overkiz/__init__.py @@ -12,6 +12,7 @@ from pyoverkiz.enums import APIType, OverkizState, UIClass, UIWidget from pyoverkiz.exceptions import ( BadCredentialsException, MaintenanceException, + NotAuthenticatedException, NotSuchTokenException, TooManyRequestsException, ) @@ -92,7 +93,11 @@ async def async_setup_entry(hass: HomeAssistant, entry: OverkizDataConfigEntry) scenarios = await client.get_scenarios() else: scenarios = [] - except (BadCredentialsException, NotSuchTokenException) as exception: + except ( + BadCredentialsException, + NotSuchTokenException, + NotAuthenticatedException, + ) as exception: raise ConfigEntryAuthFailed("Invalid authentication") from exception except TooManyRequestsException as exception: raise ConfigEntryNotReady("Too many requests, try again later") from exception diff --git a/homeassistant/components/overkiz/config_flow.py b/homeassistant/components/overkiz/config_flow.py index af955e5fb95..520e9460147 100644 --- a/homeassistant/components/overkiz/config_flow.py +++ b/homeassistant/components/overkiz/config_flow.py @@ -13,12 +13,12 @@ from pyoverkiz.exceptions import ( BadCredentialsException, CozyTouchBadCredentialsException, MaintenanceException, + NotAuthenticatedException, NotSuchTokenException, TooManyAttemptsBannedException, TooManyRequestsException, UnknownUserException, ) -from pyoverkiz.models import OverkizServer from pyoverkiz.obfuscate import obfuscate_id from pyoverkiz.utils import generate_local_server, is_overkiz_gateway import voluptuous as vol @@ -31,7 +31,6 @@ from homeassistant.const import ( CONF_USERNAME, CONF_VERIFY_SSL, ) -from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.aiohttp_client import async_create_clientsession from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo @@ -39,15 +38,12 @@ from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from .const import CONF_API_TYPE, CONF_HUB, DEFAULT_SERVER, DOMAIN, LOGGER -class DeveloperModeDisabled(HomeAssistantError): - """Error to indicate Somfy Developer Mode is disabled.""" - - class OverkizConfigFlow(ConfigFlow, domain=DOMAIN): """Handle a config flow for Overkiz (by Somfy).""" VERSION = 1 + _verify_ssl: bool = True _api_type: APIType = APIType.CLOUD _user: str | None = None _server: str = DEFAULT_SERVER @@ -57,27 +53,36 @@ class OverkizConfigFlow(ConfigFlow, domain=DOMAIN): """Validate user credentials.""" user_input[CONF_API_TYPE] = self._api_type - client = self._create_cloud_client( - username=user_input[CONF_USERNAME], - password=user_input[CONF_PASSWORD], - server=SUPPORTED_SERVERS[user_input[CONF_HUB]], - ) - await client.login(register_event_listener=False) - - # For Local API, we create and activate a local token if self._api_type == APIType.LOCAL: - user_input[CONF_TOKEN] = await self._create_local_api_token( - cloud_client=client, - host=user_input[CONF_HOST], + user_input[CONF_VERIFY_SSL] = self._verify_ssl + session = async_create_clientsession( + self.hass, verify_ssl=user_input[CONF_VERIFY_SSL] + ) + client = OverkizClient( + username="", + password="", + token=user_input[CONF_TOKEN], + session=session, + server=generate_local_server(host=user_input[CONF_HOST]), verify_ssl=user_input[CONF_VERIFY_SSL], ) + else: # APIType.CLOUD + session = async_create_clientsession(self.hass) + client = OverkizClient( + username=user_input[CONF_USERNAME], + password=user_input[CONF_PASSWORD], + server=SUPPORTED_SERVERS[user_input[CONF_HUB]], + session=session, + ) + + await client.login(register_event_listener=False) # Set main gateway id as unique id if gateways := await client.get_gateways(): for gateway in gateways: if is_overkiz_gateway(gateway.id): - gateway_id = gateway.id - await self.async_set_unique_id(gateway_id, raise_on_progress=False) + await self.async_set_unique_id(gateway.id, raise_on_progress=False) + break return user_input @@ -141,15 +146,13 @@ class OverkizConfigFlow(ConfigFlow, domain=DOMAIN): if user_input: self._user = user_input[CONF_USERNAME] - - # inherit the server from previous step user_input[CONF_HUB] = self._server try: await self.async_validate_input(user_input) except TooManyRequestsException: errors["base"] = "too_many_requests" - except BadCredentialsException as exception: + except (BadCredentialsException, NotAuthenticatedException) as exception: # If authentication with CozyTouch auth server is valid, but token is invalid # for Overkiz API server, the hardware is not supported. if user_input[CONF_HUB] in { @@ -211,16 +214,18 @@ class OverkizConfigFlow(ConfigFlow, domain=DOMAIN): if user_input: self._host = user_input[CONF_HOST] - self._user = user_input[CONF_USERNAME] - - # inherit the server from previous step + self._verify_ssl = user_input[CONF_VERIFY_SSL] user_input[CONF_HUB] = self._server try: user_input = await self.async_validate_input(user_input) except TooManyRequestsException: errors["base"] = "too_many_requests" - except BadCredentialsException: + except ( + BadCredentialsException, + NotSuchTokenException, + NotAuthenticatedException, + ): errors["base"] = "invalid_auth" except ClientConnectorCertificateError as exception: errors["base"] = "certificate_verify_failed" @@ -232,10 +237,6 @@ class OverkizConfigFlow(ConfigFlow, domain=DOMAIN): errors["base"] = "server_in_maintenance" except TooManyAttemptsBannedException: errors["base"] = "too_many_attempts" - except NotSuchTokenException: - errors["base"] = "no_such_token" - except DeveloperModeDisabled: - errors["base"] = "developer_mode_disabled" except UnknownUserException: # Somfy Protect accounts are not supported since they don't use # the Overkiz API server. Login will return unknown user. @@ -264,9 +265,8 @@ class OverkizConfigFlow(ConfigFlow, domain=DOMAIN): data_schema=vol.Schema( { vol.Required(CONF_HOST, default=self._host): str, - vol.Required(CONF_USERNAME, default=self._user): str, - vol.Required(CONF_PASSWORD): str, - vol.Required(CONF_VERIFY_SSL, default=True): bool, + vol.Required(CONF_TOKEN): str, + vol.Required(CONF_VERIFY_SSL, default=self._verify_ssl): bool, } ), description_placeholders=description_placeholders, @@ -320,64 +320,15 @@ class OverkizConfigFlow(ConfigFlow, domain=DOMAIN): self, entry_data: Mapping[str, Any] ) -> ConfigFlowResult: """Handle reauth.""" - # overkiz entries always have unique IDs + # Overkiz entries always have unique IDs self.context["title_placeholders"] = {"gateway_id": cast(str, self.unique_id)} - - self._user = entry_data[CONF_USERNAME] - self._server = entry_data[CONF_HUB] self._api_type = entry_data.get(CONF_API_TYPE, APIType.CLOUD) + self._server = entry_data[CONF_HUB] if self._api_type == APIType.LOCAL: self._host = entry_data[CONF_HOST] + self._verify_ssl = entry_data[CONF_VERIFY_SSL] + else: + self._user = entry_data[CONF_USERNAME] return await self.async_step_user(dict(entry_data)) - - def _create_cloud_client( - self, username: str, password: str, server: OverkizServer - ) -> OverkizClient: - session = async_create_clientsession(self.hass) - return OverkizClient( - username=username, password=password, server=server, session=session - ) - - async def _create_local_api_token( - self, cloud_client: OverkizClient, host: str, verify_ssl: bool - ) -> str: - """Create local API token.""" - # Create session on Somfy cloud server to generate an access token for local API - gateways = await cloud_client.get_gateways() - - gateway_id = "" - for gateway in gateways: - # Overkiz can return multiple gateways, but we only can generate a token - # for the main gateway. - if is_overkiz_gateway(gateway.id): - gateway_id = gateway.id - - developer_mode = await cloud_client.get_setup_option( - f"developerMode-{gateway_id}" - ) - - if developer_mode is None: - raise DeveloperModeDisabled - - token = await cloud_client.generate_local_token(gateway_id) - await cloud_client.activate_local_token( - gateway_id=gateway_id, token=token, label="Home Assistant/local" - ) - - session = async_create_clientsession(self.hass, verify_ssl=verify_ssl) - - # Local API - local_client = OverkizClient( - username="", - password="", - token=token, - session=session, - server=generate_local_server(host=host), - verify_ssl=verify_ssl, - ) - - await local_client.login() - - return token diff --git a/homeassistant/components/overkiz/coordinator.py b/homeassistant/components/overkiz/coordinator.py index 4b79cfc9c06..598bf4b06d0 100644 --- a/homeassistant/components/overkiz/coordinator.py +++ b/homeassistant/components/overkiz/coordinator.py @@ -79,7 +79,7 @@ class OverkizDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Device]]): """Fetch Overkiz data via event listener.""" try: events = await self.client.fetch_events() - except BadCredentialsException as exception: + except (BadCredentialsException, NotAuthenticatedException) as exception: raise ConfigEntryAuthFailed("Invalid authentication.") from exception except TooManyConcurrentRequestsException as exception: raise UpdateFailed("Too many concurrent requests.") from exception @@ -98,7 +98,7 @@ class OverkizDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Device]]): try: await self.client.login() self.devices = await self._get_devices() - except BadCredentialsException as exception: + except (BadCredentialsException, NotAuthenticatedException) as exception: raise ConfigEntryAuthFailed("Invalid authentication.") from exception except TooManyRequestsException as exception: raise UpdateFailed("Too many requests, try again later.") from exception diff --git a/homeassistant/components/overkiz/strings.json b/homeassistant/components/overkiz/strings.json index 363147150dc..c2b9dd58743 100644 --- a/homeassistant/components/overkiz/strings.json +++ b/homeassistant/components/overkiz/strings.json @@ -32,17 +32,15 @@ } }, "local": { - "description": "By activating the [Developer Mode of your TaHoma box](https://github.com/Somfy-Developer/Somfy-TaHoma-Developer-Mode#getting-started), you can authorize third-party software (like Home Assistant) to connect to it via your local network.\n\nAfter activation, enter your application credentials and change the host to include your Gateway PIN or enter the IP address of your gateway.", + "description": "By activating the [Developer Mode of your TaHoma box](https://github.com/Somfy-Developer/Somfy-TaHoma-Developer-Mode#getting-started), you can authorize third-party software (like Home Assistant) to connect to it via your local network.\n\n1. Open the TaHoma By Somfy application on your device.\n2. Navigate to the Help & advanced features -> Advanced features menu in the application.\n3. Activate Developer Mode by tapping 7 times on the version number of your gateway (like 2025.1.4-11).\n4. Generate a token from the Developer Mode menu to authenticate your API calls.\n\n5. Enter the generated token below and update the host to include your Gateway PIN or the IP address of your gateway.", "data": { "host": "[%key:common::config_flow::data::host%]", - "username": "[%key:common::config_flow::data::username%]", - "password": "[%key:common::config_flow::data::password%]", + "token": "[%key:common::config_flow::data::api_token%]", "verify_ssl": "[%key:common::config_flow::data::verify_ssl%]" }, "data_description": { "host": "The hostname or IP address of your Overkiz hub.", - "username": "The username of your cloud account (app).", - "password": "The password of your cloud account (app).", + "token": "Token generated by the app used to control your device.", "verify_ssl": "Verify the SSL certificate. Select this only if you are connecting via the hostname." } } diff --git a/tests/components/overkiz/test_config_flow.py b/tests/components/overkiz/test_config_flow.py index 711cc6c1d86..5c98b4e9260 100644 --- a/tests/components/overkiz/test_config_flow.py +++ b/tests/components/overkiz/test_config_flow.py @@ -40,6 +40,7 @@ TEST_GATEWAY_ID3 = "SOMFY_PROTECT-v0NT53occUBPyuJRzx59kalW1hFfzimN" TEST_HOST = "gateway-1234-5678-9123.local:8443" TEST_HOST2 = "192.168.11.104:8443" +TEST_TOKEN = "1234123412341234" MOCK_GATEWAY_RESPONSE = [Mock(id=TEST_GATEWAY_ID)] MOCK_GATEWAY2_RESPONSE = [Mock(id=TEST_GATEWAY_ID3), Mock(id=TEST_GATEWAY_ID2)] @@ -152,7 +153,7 @@ async def test_form_only_cloud_supported( async def test_form_local_happy_flow( hass: HomeAssistant, mock_setup_entry: AsyncMock ) -> None: - """Test we get the form.""" + """Test local API configuration flow.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) @@ -179,21 +180,27 @@ async def test_form_local_happy_flow( "pyoverkiz.client.OverkizClient", login=AsyncMock(return_value=True), get_gateways=AsyncMock(return_value=MOCK_GATEWAY_RESPONSE), - get_setup_option=AsyncMock(return_value=True), - generate_local_token=AsyncMock(return_value="1234123412341234"), - activate_local_token=AsyncMock(return_value=True), ): - await hass.config_entries.flow.async_configure( + result4 = await hass.config_entries.flow.async_configure( result["flow_id"], { - "username": TEST_EMAIL, - "password": TEST_PASSWORD, "host": "gateway-1234-5678-1234.local:8443", + "token": TEST_TOKEN, + "verify_ssl": True, }, ) await hass.async_block_till_done() + assert result4["type"] is FlowResultType.CREATE_ENTRY + assert result4["title"] == "gateway-1234-5678-1234.local:8443" + assert result4["data"] == { + "host": "gateway-1234-5678-1234.local:8443", + "token": TEST_TOKEN, + "verify_ssl": True, + "hub": TEST_SERVER, + "api_type": "local", + } assert len(mock_setup_entry.mock_calls) == 1 @@ -262,7 +269,7 @@ async def test_form_invalid_auth_cloud( (MaintenanceException, "server_in_maintenance"), (TooManyAttemptsBannedException, "too_many_attempts"), (UnknownUserException, "unsupported_hardware"), - (NotSuchTokenException, "no_such_token"), + (NotSuchTokenException, "invalid_auth"), (Exception, "unknown"), ], ) @@ -297,8 +304,7 @@ async def test_form_invalid_auth_local( result["flow_id"], { "host": TEST_HOST, - "username": TEST_EMAIL, - "password": TEST_PASSWORD, + "token": TEST_TOKEN, "verify_ssl": True, }, ) @@ -309,52 +315,6 @@ async def test_form_invalid_auth_local( assert result4["errors"] == {"base": error} -async def test_form_local_developer_mode_disabled( - hass: HomeAssistant, mock_setup_entry: AsyncMock -) -> 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 - - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - {"hub": TEST_SERVER}, - ) - - assert result2["type"] is FlowResultType.FORM - assert result2["step_id"] == "local_or_cloud" - - result3 = await hass.config_entries.flow.async_configure( - result["flow_id"], - {"api_type": "local"}, - ) - - assert result3["type"] is FlowResultType.FORM - assert result3["step_id"] == "local" - - with patch.multiple( - "pyoverkiz.client.OverkizClient", - login=AsyncMock(return_value=True), - get_gateways=AsyncMock(return_value=MOCK_GATEWAY_RESPONSE), - get_setup_option=AsyncMock(return_value=None), - ): - result4 = await hass.config_entries.flow.async_configure( - result["flow_id"], - { - "username": TEST_EMAIL, - "password": TEST_PASSWORD, - "host": "gateway-1234-5678-1234.local:8443", - "verify_ssl": True, - }, - ) - - assert result4["type"] is FlowResultType.FORM - assert result4["errors"] == {"base": "developer_mode_disabled"} - - @pytest.mark.parametrize( ("side_effect", "error"), [ @@ -444,16 +404,18 @@ async def test_cloud_abort_on_duplicate_entry( async def test_local_abort_on_duplicate_entry( hass: HomeAssistant, mock_setup_entry: AsyncMock ) -> None: - """Test we get the form.""" + """Test local API configuration is aborted if gateway already exists.""" MockConfigEntry( domain=DOMAIN, unique_id=TEST_GATEWAY_ID, + version=2, data={ "host": TEST_HOST, - "username": TEST_EMAIL, - "password": TEST_PASSWORD, + "token": TEST_TOKEN, + "verify_ssl": True, "hub": TEST_SERVER, + "api_type": "local", }, ).add_to_hass(hass) @@ -484,15 +446,12 @@ async def test_local_abort_on_duplicate_entry( login=AsyncMock(return_value=True), get_gateways=AsyncMock(return_value=MOCK_GATEWAY_RESPONSE), get_setup_option=AsyncMock(return_value=True), - generate_local_token=AsyncMock(return_value="1234123412341234"), - activate_local_token=AsyncMock(return_value=True), ): result4 = await hass.config_entries.flow.async_configure( result["flow_id"], { "host": TEST_HOST, - "username": TEST_EMAIL, - "password": TEST_PASSWORD, + "token": TEST_TOKEN, "verify_ssl": True, }, ) @@ -639,18 +598,18 @@ async def test_cloud_reauth_wrong_account(hass: HomeAssistant) -> None: assert result2["reason"] == "reauth_wrong_account" -async def test_local_reauth_success(hass: HomeAssistant) -> None: - """Test reauthentication flow.""" - +async def test_local_reauth_legacy(hass: HomeAssistant) -> None: + """Test legacy reauthentication flow with username/password.""" mock_entry = MockConfigEntry( domain=DOMAIN, unique_id=TEST_GATEWAY_ID, version=2, data={ + "host": TEST_HOST, "username": TEST_EMAIL, "password": TEST_PASSWORD, + "verify_ssl": True, "hub": TEST_SERVER, - "host": TEST_HOST, "api_type": "local", }, ) @@ -672,36 +631,85 @@ async def test_local_reauth_success(hass: HomeAssistant) -> None: "pyoverkiz.client.OverkizClient", login=AsyncMock(return_value=True), get_gateways=AsyncMock(return_value=MOCK_GATEWAY_RESPONSE), - get_setup_option=AsyncMock(return_value=True), - generate_local_token=AsyncMock(return_value="1234123412341234"), - activate_local_token=AsyncMock(return_value=True), ): result3 = await hass.config_entries.flow.async_configure( result["flow_id"], - user_input={ - "username": TEST_EMAIL, - "password": TEST_PASSWORD2, + { + "host": TEST_HOST, + "token": "new_token", + "verify_ssl": True, }, ) assert result3["type"] is FlowResultType.ABORT assert result3["reason"] == "reauth_successful" - assert mock_entry.data["username"] == TEST_EMAIL - assert mock_entry.data["password"] == TEST_PASSWORD2 + assert mock_entry.data["host"] == TEST_HOST + assert mock_entry.data["token"] == "new_token" + assert mock_entry.data["verify_ssl"] is True + + +async def test_local_reauth_success(hass: HomeAssistant) -> None: + """Test modern local reauth flow.""" + mock_entry = MockConfigEntry( + domain=DOMAIN, + unique_id=TEST_GATEWAY_ID, + version=2, + data={ + "host": TEST_HOST, + "token": "old_token", + "verify_ssl": True, + "hub": TEST_SERVER, + "api_type": "local", + }, + ) + mock_entry.add_to_hass(hass) + + result = await mock_entry.start_reauth_flow(hass) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "local_or_cloud" + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"api_type": "local"}, + ) + + assert result2["step_id"] == "local" + + with patch.multiple( + "pyoverkiz.client.OverkizClient", + login=AsyncMock(return_value=True), + get_gateways=AsyncMock(return_value=MOCK_GATEWAY_RESPONSE), + ): + result3 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "host": TEST_HOST, + "token": "new_token", + "verify_ssl": True, + }, + ) + + assert result3["type"] is FlowResultType.ABORT + assert result3["reason"] == "reauth_successful" + assert mock_entry.data["host"] == TEST_HOST + assert mock_entry.data["token"] == "new_token" + assert mock_entry.data["verify_ssl"] is True + assert "username" not in mock_entry.data + assert "password" not in mock_entry.data async def test_local_reauth_wrong_account(hass: HomeAssistant) -> None: - """Test reauthentication flow.""" + """Test local reauth flow with wrong gateway account.""" mock_entry = MockConfigEntry( domain=DOMAIN, unique_id=TEST_GATEWAY_ID2, version=2, data={ - "username": TEST_EMAIL, - "password": TEST_PASSWORD, - "hub": TEST_SERVER, "host": TEST_HOST, + "token": "old_token", + "verify_ssl": True, + "hub": TEST_SERVER, "api_type": "local", }, ) @@ -722,15 +730,13 @@ async def test_local_reauth_wrong_account(hass: HomeAssistant) -> None: "pyoverkiz.client.OverkizClient", login=AsyncMock(return_value=True), get_gateways=AsyncMock(return_value=MOCK_GATEWAY_RESPONSE), - get_setup_option=AsyncMock(return_value=True), - generate_local_token=AsyncMock(return_value="1234123412341234"), - activate_local_token=AsyncMock(return_value=True), ): result3 = await hass.config_entries.flow.async_configure( result["flow_id"], - user_input={ - "username": TEST_EMAIL, - "password": TEST_PASSWORD2, + { + "host": TEST_HOST, + "token": "new_token", + "verify_ssl": True, }, ) @@ -897,27 +903,27 @@ async def test_local_zeroconf_flow( "pyoverkiz.client.OverkizClient", login=AsyncMock(return_value=True), get_gateways=AsyncMock(return_value=MOCK_GATEWAY_RESPONSE), - get_setup_option=AsyncMock(return_value=True), - generate_local_token=AsyncMock(return_value="1234123412341234"), - activate_local_token=AsyncMock(return_value=True), ): result4 = await hass.config_entries.flow.async_configure( result["flow_id"], - {"username": TEST_EMAIL, "password": TEST_PASSWORD, "verify_ssl": False}, + { + "host": "gateway-1234-5678-9123.local:8443", + "token": TEST_TOKEN, + "verify_ssl": False, + }, ) assert result4["type"] is FlowResultType.CREATE_ENTRY assert result4["title"] == "gateway-1234-5678-9123.local:8443" - assert result4["data"] == { - "username": TEST_EMAIL, - "password": TEST_PASSWORD, - "hub": TEST_SERVER, - "host": "gateway-1234-5678-9123.local:8443", - "api_type": "local", - "token": "1234123412341234", - "verify_ssl": False, - } + # Verify no username/password in data + assert result4["data"] == { + "host": "gateway-1234-5678-9123.local:8443", + "token": TEST_TOKEN, + "verify_ssl": False, + "hub": TEST_SERVER, + "api_type": "local", + } assert len(mock_setup_entry.mock_calls) == 1 From b5b934b8a19c4da29090402bdcfb85e488e23ced Mon Sep 17 00:00:00 2001 From: tdfountain <174762217+tdfountain@users.noreply.github.com> Date: Sat, 19 Apr 2025 23:30:15 -0700 Subject: [PATCH 26/70] Use _get_reauth_entry rather than storing in flow for NUT (#143308) Use _get_reauth_entry rather than storing in flow --- homeassistant/components/nut/config_flow.py | 17 ++++++----------- 1 file changed, 6 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/nut/config_flow.py b/homeassistant/components/nut/config_flow.py index eb13e4a168b..aad596f6dfb 100644 --- a/homeassistant/components/nut/config_flow.py +++ b/homeassistant/components/nut/config_flow.py @@ -319,8 +319,6 @@ class NutConfigFlow(ConfigFlow, domain=DOMAIN): self, entry_data: Mapping[str, Any] ) -> ConfigFlowResult: """Handle reauth.""" - entry_id = self.context["entry_id"] - self.reauth_entry = self.hass.config_entries.async_get_entry(entry_id) return await self.async_step_reauth_confirm() async def async_step_reauth_confirm( @@ -329,17 +327,16 @@ class NutConfigFlow(ConfigFlow, domain=DOMAIN): """Handle reauth input.""" errors: dict[str, str] = {} - existing_entry = self.reauth_entry - assert existing_entry - existing_data = existing_entry.data + reauth_entry = self._get_reauth_entry() + reauth_data = reauth_entry.data description_placeholders: dict[str, str] = { - CONF_HOST: existing_data[CONF_HOST], - CONF_PORT: existing_data[CONF_PORT], + CONF_HOST: reauth_data[CONF_HOST], + CONF_PORT: reauth_data[CONF_PORT], } if user_input is not None: new_config = { - **existing_data, + **reauth_data, # Username/password are optional and some servers # use ip based authentication and will fail if # username/password are provided @@ -348,9 +345,7 @@ class NutConfigFlow(ConfigFlow, domain=DOMAIN): } _, errors, placeholders = await self._async_validate_or_error(new_config) if not errors: - return self.async_update_reload_and_abort( - existing_entry, data=new_config - ) + return self.async_update_reload_and_abort(reauth_entry, data=new_config) description_placeholders.update(placeholders) return self.async_show_form( From a749ecceedb5279e791e6d40868e9fde134cbd91 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Sun, 20 Apr 2025 09:28:30 +0200 Subject: [PATCH 27/70] Add helper method to clear logger overwrites for tests (#143301) --- homeassistant/components/logger/__init__.py | 4 +++- homeassistant/components/logger/helpers.py | 19 +++++++++++-------- .../components/logger/websocket_api.py | 6 +++--- tests/common.py | 8 ++++++-- tests/components/logger/test_websocket_api.py | 16 ++++++++-------- 5 files changed, 31 insertions(+), 22 deletions(-) diff --git a/homeassistant/components/logger/__init__.py b/homeassistant/components/logger/__init__.py index 15283b246b2..8593b3c478e 100644 --- a/homeassistant/components/logger/__init__.py +++ b/homeassistant/components/logger/__init__.py @@ -24,8 +24,10 @@ from .const import ( SERVICE_SET_LEVEL, ) from .helpers import ( + DATA_LOGGER, LoggerDomainConfig, LoggerSettings, + _clear_logger_overwrites, # noqa: F401 set_default_log_level, set_log_levels, ) @@ -54,7 +56,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: settings = LoggerSettings(hass, config) - domain_config = hass.data[DOMAIN] = LoggerDomainConfig({}, settings) + domain_config = hass.data[DATA_LOGGER] = LoggerDomainConfig({}, settings) logging.setLoggerClass(_get_logger_class(domain_config.overrides)) websocket_api.async_load_websocket_api(hass) diff --git a/homeassistant/components/logger/helpers.py b/homeassistant/components/logger/helpers.py index 00cea7e8aa5..19afe18e3fe 100644 --- a/homeassistant/components/logger/helpers.py +++ b/homeassistant/components/logger/helpers.py @@ -9,13 +9,14 @@ from dataclasses import asdict, dataclass from enum import StrEnum from functools import lru_cache import logging -from typing import Any, cast +from typing import Any from homeassistant.const import EVENT_LOGGING_CHANGED from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.storage import Store from homeassistant.helpers.typing import ConfigType from homeassistant.loader import IntegrationNotFound, async_get_integration +from homeassistant.util.hass_dict import HassKey from .const import ( DOMAIN, @@ -28,6 +29,8 @@ from .const import ( STORAGE_VERSION, ) +DATA_LOGGER: HassKey[LoggerDomainConfig] = HassKey(DOMAIN) + SAVE_DELAY = 15.0 # At startup, we want to save after a long delay to avoid # saving while the system is still starting up. If the system @@ -39,12 +42,6 @@ SAVE_DELAY = 15.0 SAVE_DELAY_LONG = 180.0 -@callback -def async_get_domain_config(hass: HomeAssistant) -> LoggerDomainConfig: - """Return the domain config.""" - return cast(LoggerDomainConfig, hass.data[DOMAIN]) - - @callback def set_default_log_level(hass: HomeAssistant, level: int) -> None: """Set the default log level for components.""" @@ -55,7 +52,7 @@ def set_default_log_level(hass: HomeAssistant, level: int) -> None: @callback def set_log_levels(hass: HomeAssistant, logpoints: Mapping[str, int]) -> None: """Set the specified log levels.""" - async_get_domain_config(hass).overrides.update(logpoints) + hass.data[DATA_LOGGER].overrides.update(logpoints) for key, value in logpoints.items(): _set_log_level(logging.getLogger(key), value) hass.bus.async_fire(EVENT_LOGGING_CHANGED) @@ -78,6 +75,12 @@ def _chattiest_log_level(level1: int, level2: int) -> int: return min(level1, level2) +@callback +def _clear_logger_overwrites(hass: HomeAssistant) -> None: + """Clear logger overwrites. Used for testing.""" + hass.data[DATA_LOGGER].overrides.clear() + + async def get_integration_loggers(hass: HomeAssistant, domain: str) -> set[str]: """Get loggers for an integration.""" loggers: set[str] = {f"homeassistant.components.{domain}"} diff --git a/homeassistant/components/logger/websocket_api.py b/homeassistant/components/logger/websocket_api.py index 2430f187a6f..041fe417698 100644 --- a/homeassistant/components/logger/websocket_api.py +++ b/homeassistant/components/logger/websocket_api.py @@ -12,10 +12,10 @@ from homeassistant.setup import async_get_loaded_integrations from .const import LOGSEVERITY from .helpers import ( + DATA_LOGGER, LoggerSetting, LogPersistance, LogSettingsType, - async_get_domain_config, get_logger, ) @@ -68,7 +68,7 @@ async def handle_integration_log_level( msg["id"], websocket_api.ERR_NOT_FOUND, "Integration not found" ) return - await async_get_domain_config(hass).settings.async_update( + await hass.data[DATA_LOGGER].settings.async_update( hass, msg["integration"], LoggerSetting( @@ -93,7 +93,7 @@ async def handle_module_log_level( hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] ) -> None: """Handle setting integration log level.""" - await async_get_domain_config(hass).settings.async_update( + await hass.data[DATA_LOGGER].settings.async_update( hass, msg["module"], LoggerSetting( diff --git a/tests/common.py b/tests/common.py index 8f06aa54383..0bc4d61b639 100644 --- a/tests/common.py +++ b/tests/common.py @@ -46,7 +46,11 @@ from homeassistant.components import device_automation, persistent_notification from homeassistant.components.device_automation import ( # noqa: F401 _async_get_device_automation_capabilities as async_get_device_automation_capabilities, ) -from homeassistant.components.logger import DOMAIN as LOGGER_DOMAIN, SERVICE_SET_LEVEL +from homeassistant.components.logger import ( + DOMAIN as LOGGER_DOMAIN, + SERVICE_SET_LEVEL, + _clear_logger_overwrites, +) from homeassistant.config import IntegrationConfigInfo, async_process_component_config from homeassistant.config_entries import ConfigEntry, ConfigFlow, ConfigFlowResult from homeassistant.const import ( @@ -1708,7 +1712,7 @@ async def async_call_logger_set_level( ) await hass.async_block_till_done() yield - hass.data[LOGGER_DOMAIN].overrides.clear() + _clear_logger_overwrites(hass) def import_and_test_deprecated_constant_enum( diff --git a/tests/components/logger/test_websocket_api.py b/tests/components/logger/test_websocket_api.py index 8fcafcd05a4..debe26576bd 100644 --- a/tests/components/logger/test_websocket_api.py +++ b/tests/components/logger/test_websocket_api.py @@ -4,7 +4,7 @@ import logging from unittest.mock import patch from homeassistant import loader -from homeassistant.components.logger.helpers import async_get_domain_config +from homeassistant.components.logger.helpers import DATA_LOGGER from homeassistant.components.websocket_api import TYPE_RESULT from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component @@ -76,7 +76,7 @@ async def test_integration_log_level( assert msg["type"] == TYPE_RESULT assert msg["success"] - assert async_get_domain_config(hass).overrides == { + assert hass.data[DATA_LOGGER].overrides == { "homeassistant.components.websocket_api": logging.DEBUG } @@ -126,7 +126,7 @@ async def test_custom_integration_log_level( assert msg["type"] == TYPE_RESULT assert msg["success"] - assert async_get_domain_config(hass).overrides == { + assert hass.data[DATA_LOGGER].overrides == { "homeassistant.components.hue": logging.DEBUG, "custom_components.hue": logging.DEBUG, "some_other_logger": logging.DEBUG, @@ -182,7 +182,7 @@ async def test_module_log_level( assert msg["type"] == TYPE_RESULT assert msg["success"] - assert async_get_domain_config(hass).overrides == { + assert hass.data[DATA_LOGGER].overrides == { "homeassistant.components.websocket_api": logging.DEBUG, "homeassistant.components.other_component": logging.WARNING, } @@ -199,7 +199,7 @@ async def test_module_log_level_override( {"logger": {"logs": {"homeassistant.components.websocket_api": "warning"}}}, ) - assert async_get_domain_config(hass).overrides == { + assert hass.data[DATA_LOGGER].overrides == { "homeassistant.components.websocket_api": logging.WARNING } @@ -218,7 +218,7 @@ async def test_module_log_level_override( assert msg["type"] == TYPE_RESULT assert msg["success"] - assert async_get_domain_config(hass).overrides == { + assert hass.data[DATA_LOGGER].overrides == { "homeassistant.components.websocket_api": logging.ERROR } @@ -237,7 +237,7 @@ async def test_module_log_level_override( assert msg["type"] == TYPE_RESULT assert msg["success"] - assert async_get_domain_config(hass).overrides == { + assert hass.data[DATA_LOGGER].overrides == { "homeassistant.components.websocket_api": logging.DEBUG } @@ -256,6 +256,6 @@ async def test_module_log_level_override( assert msg["type"] == TYPE_RESULT assert msg["success"] - assert async_get_domain_config(hass).overrides == { + assert hass.data[DATA_LOGGER].overrides == { "homeassistant.components.websocket_api": logging.NOTSET } From 0bed5727cb2c5f2dd3155fc35fdb64288843800a Mon Sep 17 00:00:00 2001 From: Arjan <44190435+vingerha@users.noreply.github.com> Date: Sun, 20 Apr 2025 09:53:40 +0200 Subject: [PATCH 28/70] Linkplay: bump lib to 0.2.4 (#143313) --- homeassistant/components/linkplay/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/linkplay/manifest.json b/homeassistant/components/linkplay/manifest.json index b57a7b68881..69a7b71eeb6 100644 --- a/homeassistant/components/linkplay/manifest.json +++ b/homeassistant/components/linkplay/manifest.json @@ -7,6 +7,6 @@ "integration_type": "hub", "iot_class": "local_polling", "loggers": ["linkplay"], - "requirements": ["python-linkplay==0.2.3"], + "requirements": ["python-linkplay==0.2.4"], "zeroconf": ["_linkplay._tcp.local."] } diff --git a/requirements_all.txt b/requirements_all.txt index f34ab4a2d55..5ca397020be 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2436,7 +2436,7 @@ python-juicenet==1.1.0 python-kasa[speedups]==0.10.2 # homeassistant.components.linkplay -python-linkplay==0.2.3 +python-linkplay==0.2.4 # homeassistant.components.lirc # python-lirc==1.2.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 82d6baed915..dadc8d4a764 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1976,7 +1976,7 @@ python-juicenet==1.1.0 python-kasa[speedups]==0.10.2 # homeassistant.components.linkplay -python-linkplay==0.2.3 +python-linkplay==0.2.4 # homeassistant.components.matter python-matter-server==7.0.0 From eb852cec43f3097b6ba75e1bcb45419789323e24 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Sun, 20 Apr 2025 09:55:50 +0200 Subject: [PATCH 29/70] Use common state for "Error" in `tesla_wall_connector` (#143272) --- homeassistant/components/tesla_wall_connector/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/tesla_wall_connector/strings.json b/homeassistant/components/tesla_wall_connector/strings.json index b356a9f3ebc..f1247ea8f9f 100644 --- a/homeassistant/components/tesla_wall_connector/strings.json +++ b/homeassistant/components/tesla_wall_connector/strings.json @@ -38,7 +38,7 @@ "connected": "Vehicle connected", "ready": "Ready to charge", "negotiating": "Negotiating connection", - "error": "Error", + "error": "[%key:common::state::error%]", "charging_finished": "Charging finished", "waiting_car": "Waiting for car", "charging_reduced": "Charging (reduced)", From e1ba2a8ca270ee30b52d1fa8c4ffdbba26c44c5f Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Sun, 20 Apr 2025 09:56:12 +0200 Subject: [PATCH 30/70] Use common state for "Error" in `matter` (#143268) --- homeassistant/components/matter/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/matter/strings.json b/homeassistant/components/matter/strings.json index f6e7187f8c0..fedb026bf25 100644 --- a/homeassistant/components/matter/strings.json +++ b/homeassistant/components/matter/strings.json @@ -270,7 +270,7 @@ "stopped": "[%key:common::state::stopped%]", "running": "Running", "paused": "[%key:common::state::paused%]", - "error": "Error", + "error": "[%key:common::state::error%]", "seeking_charger": "Seeking charger", "charging": "[%key:common::state::charging%]", "docked": "Docked" From 8b0f9d431774de1183d677394ad0e842e4e95474 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Sun, 20 Apr 2025 09:56:27 +0200 Subject: [PATCH 31/70] Use common state for "Error" in `aranet` (#143282) --- homeassistant/components/aranet/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/aranet/strings.json b/homeassistant/components/aranet/strings.json index f786f4b2d4d..bb2ea3b2887 100644 --- a/homeassistant/components/aranet/strings.json +++ b/homeassistant/components/aranet/strings.json @@ -26,7 +26,7 @@ "sensor": { "threshold": { "state": { - "error": "Error", + "error": "[%key:common::state::error%]", "green": "Green", "yellow": "Yellow", "red": "Red" From b29c295adc8b2fb5bbe429dc4ee54739f98a1d9c Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Sun, 20 Apr 2025 09:57:00 +0200 Subject: [PATCH 32/70] Use common state for "Error" in `jvc_projector` (#143283) --- homeassistant/components/jvc_projector/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/jvc_projector/strings.json b/homeassistant/components/jvc_projector/strings.json index c6e5736bd2d..ab17ef6e8ff 100644 --- a/homeassistant/components/jvc_projector/strings.json +++ b/homeassistant/components/jvc_projector/strings.json @@ -56,7 +56,7 @@ "on": "[%key:common::state::on%]", "warming": "Warming", "cooling": "Cooling", - "error": "Error" + "error": "[%key:common::state::error%]" } } } From 6f178a8a23f8f2bdad6b73f0736dc64f668ccdf3 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Sun, 20 Apr 2025 10:37:07 +0200 Subject: [PATCH 33/70] Use common state for "Error", capitalize "1P" in `lektrico` (#143315) - replace "Error" with new common state reference - capitalize the abbreviation "1P" (single phase) --- homeassistant/components/lektrico/strings.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/lektrico/strings.json b/homeassistant/components/lektrico/strings.json index eb223b4758b..23aac0b3059 100644 --- a/homeassistant/components/lektrico/strings.json +++ b/homeassistant/components/lektrico/strings.json @@ -88,7 +88,7 @@ "available": "Available", "charging": "[%key:common::state::charging%]", "connected": "[%key:common::state::connected%]", - "error": "Error", + "error": "[%key:common::state::error%]", "locked": "[%key:common::state::locked%]", "need_auth": "Waiting for authentication", "paused": "[%key:common::state::paused%]", @@ -118,7 +118,7 @@ "ocpp": "OCPP", "overtemperature": "Overtemperature", "switching_phases": "Switching phases", - "1p_charging_disabled": "1p charging disabled" + "1p_charging_disabled": "1P charging disabled" } }, "breaker_current": { From 29b67505a7489b489ebb0d358de3d634e28be8c6 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Sun, 20 Apr 2025 10:37:27 +0200 Subject: [PATCH 34/70] Use common state for "Error" in `bmw_connected_drive` (#143316) --- homeassistant/components/bmw_connected_drive/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/bmw_connected_drive/strings.json b/homeassistant/components/bmw_connected_drive/strings.json index bd9814476f5..d094116725f 100644 --- a/homeassistant/components/bmw_connected_drive/strings.json +++ b/homeassistant/components/bmw_connected_drive/strings.json @@ -139,7 +139,7 @@ "state": { "default": "Default", "charging": "[%key:common::state::charging%]", - "error": "Error", + "error": "[%key:common::state::error%]", "complete": "Complete", "fully_charged": "Fully charged", "finished_fully_charged": "Finished, fully charged", From fb60479578155065827e30f0018f3eeb59a82274 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Sun, 20 Apr 2025 10:49:02 +0200 Subject: [PATCH 35/70] Use common state for "Error" in `prusalink` (#143317) --- homeassistant/components/prusalink/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/prusalink/strings.json b/homeassistant/components/prusalink/strings.json index 036bd2c9c6e..6c698cf3dc2 100644 --- a/homeassistant/components/prusalink/strings.json +++ b/homeassistant/components/prusalink/strings.json @@ -37,7 +37,7 @@ "paused": "[%key:common::state::paused%]", "finished": "Finished", "stopped": "[%key:common::state::stopped%]", - "error": "Error", + "error": "[%key:common::state::error%]", "attention": "Attention", "ready": "Ready" } From 521a44b9533a0bf9fb03ff4ee693a2aa1894b4af Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Sun, 20 Apr 2025 10:49:17 +0200 Subject: [PATCH 36/70] Use common state for "Error" in `roborock` (#143318) --- homeassistant/components/roborock/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/roborock/strings.json b/homeassistant/components/roborock/strings.json index 0f36fbec3d5..b68d747e9a2 100644 --- a/homeassistant/components/roborock/strings.json +++ b/homeassistant/components/roborock/strings.json @@ -232,7 +232,7 @@ "charging_problem": "Charging problem", "paused": "[%key:common::state::paused%]", "spot_cleaning": "Spot cleaning", - "error": "Error", + "error": "[%key:common::state::error%]", "shutting_down": "Shutting down", "updating": "Updating", "docking": "Docking", From 928faeba0ddcb43b94988a9461c7a555a8d56df6 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Sun, 20 Apr 2025 10:49:34 +0200 Subject: [PATCH 37/70] Use common state for "Error" in `tessie` (#143319) --- homeassistant/components/tessie/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/tessie/strings.json b/homeassistant/components/tessie/strings.json index 1c0ec7ecc80..4c2f1ebbb68 100644 --- a/homeassistant/components/tessie/strings.json +++ b/homeassistant/components/tessie/strings.json @@ -217,7 +217,7 @@ "connected": "[%key:common::state::connected%]", "scheduled": "Scheduled", "negotiating": "Negotiating", - "error": "Error", + "error": "[%key:common::state::error%]", "charging_finished": "Charging finished", "waiting_car": "Waiting car", "charging_reduced": "Charging reduced" From 9e59f07401d3cc6f66da59efb33fca3d627fba4a Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Sun, 20 Apr 2025 10:55:36 +0200 Subject: [PATCH 38/70] Use common state for "Error" in `zha` (#143320) --- homeassistant/components/zha/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/zha/strings.json b/homeassistant/components/zha/strings.json index 79cb05c3a0e..be7add23d56 100644 --- a/homeassistant/components/zha/strings.json +++ b/homeassistant/components/zha/strings.json @@ -1509,7 +1509,7 @@ "name": "Software error", "state": { "nothing": "Good", - "something": "Error" + "something": "[%key:common::state::error%]" }, "state_attributes": { "top_pcb_sensor_error": { From 9b2faf207d741088067c6275a7981ad574b7e00c Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Sun, 20 Apr 2025 11:49:16 +0200 Subject: [PATCH 39/70] Fix spelling of "off-peak", improve error message in `teslemetry` (#143321) --- homeassistant/components/teslemetry/strings.json | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/teslemetry/strings.json b/homeassistant/components/teslemetry/strings.json index 69b1551a561..ff9bb5d595f 100644 --- a/homeassistant/components/teslemetry/strings.json +++ b/homeassistant/components/teslemetry/strings.json @@ -662,7 +662,7 @@ "message": "Departure time required to enable preconditioning" }, "set_scheduled_departure_off_peak": { - "message": "To enable scheduled departure, end off peak time is required." + "message": "To enable scheduled departure, 'End off-peak time' is required." }, "invalid_device": { "message": "Invalid device ID: {device_id}" @@ -752,15 +752,15 @@ }, "end_off_peak_time": { "description": "Time to complete charging by.", - "name": "End off peak time" + "name": "End off-peak time" }, "off_peak_charging_enabled": { - "description": "Enable off peak charging.", - "name": "Off peak charging enabled" + "description": "Enable off-peak charging.", + "name": "Off-peak charging enabled" }, "off_peak_charging_weekdays_only": { - "description": "Enable off peak charging on weekdays only.", - "name": "Off peak charging weekdays only" + "description": "Enable off-peak charging on weekdays only.", + "name": "Off-peak charging weekdays only" }, "preconditioning_enabled": { "description": "Enable preconditioning.", From b76cddcf9f5cf2aaaf4ff0d08a65d355496e4837 Mon Sep 17 00:00:00 2001 From: David Knowles Date: Sun, 20 Apr 2025 13:35:50 -0400 Subject: [PATCH 40/70] Bump pyschlage to 2025.4.0 (#143345) --- homeassistant/components/schlage/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/schlage/manifest.json b/homeassistant/components/schlage/manifest.json index 61cc2a3c63d..893c30dfd41 100644 --- a/homeassistant/components/schlage/manifest.json +++ b/homeassistant/components/schlage/manifest.json @@ -5,5 +5,5 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/schlage", "iot_class": "cloud_polling", - "requirements": ["pyschlage==2024.11.0"] + "requirements": ["pyschlage==2025.4.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 5ca397020be..a25d35febca 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2289,7 +2289,7 @@ pysabnzbd==1.1.1 pysaj==0.0.16 # homeassistant.components.schlage -pyschlage==2024.11.0 +pyschlage==2025.4.0 # homeassistant.components.sensibo pysensibo==1.1.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index dadc8d4a764..0ee9792eb2a 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1871,7 +1871,7 @@ pyrympro==0.0.9 pysabnzbd==1.1.1 # homeassistant.components.schlage -pyschlage==2024.11.0 +pyschlage==2025.4.0 # homeassistant.components.sensibo pysensibo==1.1.0 From 7fea43210223adfd76b172cda46829ce0707224c Mon Sep 17 00:00:00 2001 From: Maciej Bieniek Date: Sun, 20 Apr 2025 19:42:39 +0200 Subject: [PATCH 41/70] Bump aioshelly to version 13.5.0 (#143350) --- homeassistant/components/shelly/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/shelly/manifest.json b/homeassistant/components/shelly/manifest.json index 19ccd1354a7..22f64b60727 100644 --- a/homeassistant/components/shelly/manifest.json +++ b/homeassistant/components/shelly/manifest.json @@ -8,7 +8,7 @@ "integration_type": "device", "iot_class": "local_push", "loggers": ["aioshelly"], - "requirements": ["aioshelly==13.4.1"], + "requirements": ["aioshelly==13.5.0"], "zeroconf": [ { "type": "_http._tcp.local.", diff --git a/requirements_all.txt b/requirements_all.txt index a25d35febca..179e667c398 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -371,7 +371,7 @@ aioruuvigateway==0.1.0 aiosenz==1.0.0 # homeassistant.components.shelly -aioshelly==13.4.1 +aioshelly==13.5.0 # homeassistant.components.skybell aioskybell==22.7.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 0ee9792eb2a..bef62b168cb 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -353,7 +353,7 @@ aioruuvigateway==0.1.0 aiosenz==1.0.0 # homeassistant.components.shelly -aioshelly==13.4.1 +aioshelly==13.5.0 # homeassistant.components.skybell aioskybell==22.7.0 From 6e7f49591fdb7f6cfcc54c873d5f9ac08328af8e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ville=20Skytt=C3=A4?= Date: Sun, 20 Apr 2025 20:45:03 +0300 Subject: [PATCH 42/70] Upgrade huawei-lte-api to 1.11.0 (#143351) --- homeassistant/components/huawei_lte/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/huawei_lte/manifest.json b/homeassistant/components/huawei_lte/manifest.json index ce5316553ed..e58525e3af4 100644 --- a/homeassistant/components/huawei_lte/manifest.json +++ b/homeassistant/components/huawei_lte/manifest.json @@ -7,7 +7,7 @@ "iot_class": "local_polling", "loggers": ["huawei_lte_api.Session"], "requirements": [ - "huawei-lte-api==1.10.0", + "huawei-lte-api==1.11.0", "stringcase==1.2.0", "url-normalize==2.2.0" ], diff --git a/requirements_all.txt b/requirements_all.txt index 179e667c398..d90385fb452 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1172,7 +1172,7 @@ horimote==0.4.1 httplib2==0.20.4 # homeassistant.components.huawei_lte -huawei-lte-api==1.10.0 +huawei-lte-api==1.11.0 # homeassistant.components.huum huum==0.7.12 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index bef62b168cb..eeee269c1ae 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -999,7 +999,7 @@ homematicip==2.0.0 httplib2==0.20.4 # homeassistant.components.huawei_lte -huawei-lte-api==1.10.0 +huawei-lte-api==1.11.0 # homeassistant.components.huum huum==0.7.12 From 35e26629af157e9a70f8173d0d5ba103dfbe401b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=85ke=20Strandberg?= Date: Sun, 20 Apr 2025 19:45:28 +0200 Subject: [PATCH 43/70] Bump pymiele to 0.3.6 (#143338) --- homeassistant/components/miele/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/miele/manifest.json b/homeassistant/components/miele/manifest.json index 414db320718..9cc79b099a3 100644 --- a/homeassistant/components/miele/manifest.json +++ b/homeassistant/components/miele/manifest.json @@ -8,6 +8,6 @@ "iot_class": "cloud_push", "loggers": ["pymiele"], "quality_scale": "bronze", - "requirements": ["pymiele==0.3.4"], + "requirements": ["pymiele==0.3.6"], "single_config_entry": true } diff --git a/requirements_all.txt b/requirements_all.txt index d90385fb452..d821aacba20 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2134,7 +2134,7 @@ pymeteoclimatic==0.1.0 pymicro-vad==1.0.1 # homeassistant.components.miele -pymiele==0.3.4 +pymiele==0.3.6 # homeassistant.components.xiaomi_tv pymitv==1.4.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index eeee269c1ae..ab1fe5c3336 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1746,7 +1746,7 @@ pymeteoclimatic==0.1.0 pymicro-vad==1.0.1 # homeassistant.components.miele -pymiele==0.3.4 +pymiele==0.3.6 # homeassistant.components.mochad pymochad==0.2.0 From f928818bf14c591f126241d67d8d255f3611e12f Mon Sep 17 00:00:00 2001 From: Mick Vleeshouwer Date: Sun, 20 Apr 2025 19:52:10 +0200 Subject: [PATCH 44/70] Bump pyOverkiz to 1.17.1 (#143353) --- homeassistant/components/overkiz/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/overkiz/manifest.json b/homeassistant/components/overkiz/manifest.json index 7f4be56979a..6f1af6d5aca 100644 --- a/homeassistant/components/overkiz/manifest.json +++ b/homeassistant/components/overkiz/manifest.json @@ -13,7 +13,7 @@ "integration_type": "hub", "iot_class": "local_polling", "loggers": ["boto3", "botocore", "pyhumps", "pyoverkiz", "s3transfer"], - "requirements": ["pyoverkiz==1.17.0"], + "requirements": ["pyoverkiz==1.17.1"], "zeroconf": [ { "type": "_kizbox._tcp.local.", diff --git a/requirements_all.txt b/requirements_all.txt index d821aacba20..0bf5966b0d5 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2214,7 +2214,7 @@ pyotgw==2.2.2 pyotp==2.8.0 # homeassistant.components.overkiz -pyoverkiz==1.17.0 +pyoverkiz==1.17.1 # homeassistant.components.onewire pyownet==0.10.0.post1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index ab1fe5c3336..ea91ad28008 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1811,7 +1811,7 @@ pyotgw==2.2.2 pyotp==2.8.0 # homeassistant.components.overkiz -pyoverkiz==1.17.0 +pyoverkiz==1.17.1 # homeassistant.components.onewire pyownet==0.10.0.post1 From 8699e69ae5d41311c2de8edfc9babd5dbf9fdd8b Mon Sep 17 00:00:00 2001 From: karwosts <32912880+karwosts@users.noreply.github.com> Date: Sun, 20 Apr 2025 11:08:28 -0700 Subject: [PATCH 45/70] Optimize sliding window history_stats to not re-query the database every interval (#143279) Co-authored-by: J. Nick Koston --- .../components/history_stats/data.py | 43 ++++- tests/components/history_stats/test_sensor.py | 158 +++++++++++++++--- 2 files changed, 166 insertions(+), 35 deletions(-) diff --git a/homeassistant/components/history_stats/data.py b/homeassistant/components/history_stats/data.py index a69abe26f6c..756a6b3ce9d 100644 --- a/homeassistant/components/history_stats/data.py +++ b/homeassistant/components/history_stats/data.py @@ -54,7 +54,7 @@ class HistoryStats: self._period = (MIN_TIME_UTC, MIN_TIME_UTC) self._state: HistoryStatsState = HistoryStatsState(None, None, self._period) self._history_current_period: list[HistoryState] = [] - self._previous_run_before_start = False + self._has_recorder_data = False self._entity_states = set(entity_states) self._duration = duration self._start = start @@ -88,20 +88,20 @@ class HistoryStats: if current_period_start_timestamp > now_timestamp: # History cannot tell the future self._history_current_period = [] - self._previous_run_before_start = True + self._has_recorder_data = False self._state = HistoryStatsState(None, None, self._period) return self._state # # We avoid querying the database if the below did NOT happen: # - # - The previous run happened before the start time - # - The start time changed - # - The period shrank in size + # - No previous run occurred (uninitialized) + # - The start time moved back in time + # - The end time moved back in time # - The previous period ended before now # if ( - not self._previous_run_before_start - and current_period_start_timestamp == previous_period_start_timestamp + self._has_recorder_data + and current_period_start_timestamp >= previous_period_start_timestamp and ( current_period_end_timestamp == previous_period_end_timestamp or ( @@ -110,6 +110,12 @@ class HistoryStats: ) ) ): + start_changed = ( + current_period_start_timestamp != previous_period_start_timestamp + ) + if start_changed: + self._prune_history_cache(current_period_start_timestamp) + new_data = False if event and (new_state := event.data["new_state"]) is not None: if ( @@ -121,7 +127,11 @@ class HistoryStats: HistoryState(new_state.state, new_state.last_changed_timestamp) ) new_data = True - if not new_data and current_period_end_timestamp < now_timestamp: + if ( + not new_data + and current_period_end_timestamp < now_timestamp + and not start_changed + ): # If period has not changed and current time after the period end... # Don't compute anything as the value cannot have changed return self._state @@ -139,7 +149,7 @@ class HistoryStats: HistoryState(new_state.state, new_state.last_changed_timestamp) ) - self._previous_run_before_start = False + self._has_recorder_data = True seconds_matched, match_count = self._async_compute_seconds_and_changes( now_timestamp, @@ -223,3 +233,18 @@ class HistoryStats: # Save value in seconds seconds_matched = elapsed return seconds_matched, match_count + + def _prune_history_cache(self, start_timestamp: float) -> None: + """Remove unnecessary old data from the history state cache from previous runs. + + Update the timestamp of the last record from before the start to the current start time. + """ + trim_count = 0 + for i, history_state in enumerate(self._history_current_period): + if history_state.last_changed >= start_timestamp: + break + history_state.last_changed = start_timestamp + if i > 0: + trim_count += 1 + if trim_count: # Don't slice if no data was removed + self._history_current_period = self._history_current_period[trim_count:] diff --git a/tests/components/history_stats/test_sensor.py b/tests/components/history_stats/test_sensor.py index e2dba1b9355..ee426cf3048 100644 --- a/tests/components/history_stats/test_sensor.py +++ b/tests/components/history_stats/test_sensor.py @@ -969,6 +969,135 @@ async def test_async_start_from_history_and_switch_to_watching_state_changes_mul assert hass.states.get("sensor.sensor4").state == "87.5" +async def test_start_from_history_then_watch_state_changes_sliding( + recorder_mock: Recorder, + hass: HomeAssistant, +) -> None: + """Test we startup from history and switch to watching state changes. + + With a sliding window, history_stats does not requery the recorder. + """ + await hass.config.async_set_time_zone("UTC") + utcnow = dt_util.utcnow() + start_time = utcnow.replace(hour=0, minute=0, second=0, microsecond=0) + time = start_time + + def _fake_states(*args, **kwargs): + return { + "binary_sensor.state": [ + ha.State( + "binary_sensor.state", + "off", + last_changed=start_time - timedelta(hours=1), + last_updated=start_time - timedelta(hours=1), + ), + ] + } + + with ( + patch( + "homeassistant.components.recorder.history.state_changes_during_period", + _fake_states, + ), + freeze_time(start_time), + ): + await async_setup_component( + hass, + "sensor", + { + "sensor": [ + { + "platform": "history_stats", + "entity_id": "binary_sensor.state", + "name": f"sensor{i}", + "state": "on", + "end": "{{ utcnow() }}", + "duration": {"hours": 1}, + "type": sensor_type, + } + for i, sensor_type in enumerate(["time", "ratio", "count"]) + ] + }, + ) + await hass.async_block_till_done() + + for i in range(3): + await async_update_entity(hass, f"sensor.sensor{i}") + await hass.async_block_till_done() + + assert hass.states.get("sensor.sensor0").state == "0.0" + assert hass.states.get("sensor.sensor1").state == "0.0" + assert hass.states.get("sensor.sensor2").state == "0" + + with freeze_time(time): + hass.states.async_set("binary_sensor.state", "on") + await hass.async_block_till_done() + async_fire_time_changed(hass, time) + await hass.async_block_till_done() + + assert hass.states.get("sensor.sensor0").state == "0.0" + assert hass.states.get("sensor.sensor1").state == "0.0" + assert hass.states.get("sensor.sensor2").state == "1" + + # After sensor has been on for 15 minutes, check state + time += timedelta(minutes=15) # 00:15 + with freeze_time(time): + async_fire_time_changed(hass, time) + await hass.async_block_till_done() + + assert hass.states.get("sensor.sensor0").state == "0.25" + assert hass.states.get("sensor.sensor1").state == "25.0" + assert hass.states.get("sensor.sensor2").state == "1" + + with freeze_time(time): + hass.states.async_set("binary_sensor.state", "off") + await hass.async_block_till_done() + async_fire_time_changed(hass, time) + await hass.async_block_till_done() + + time += timedelta(minutes=30) # 00:45 + + with freeze_time(time): + async_fire_time_changed(hass, time) + await hass.async_block_till_done() + + assert hass.states.get("sensor.sensor0").state == "0.25" + assert hass.states.get("sensor.sensor1").state == "25.0" + assert hass.states.get("sensor.sensor2").state == "1" + + time += timedelta(minutes=20) # 01:05 + + with freeze_time(time): + async_fire_time_changed(hass, time) + await hass.async_block_till_done() + + # Sliding window will have started to erase the initial on period, so now it will only be on for 10 minutes + assert hass.states.get("sensor.sensor0").state == "0.17" + assert hass.states.get("sensor.sensor1").state == "16.7" + assert hass.states.get("sensor.sensor2").state == "1" + + time += timedelta(minutes=5) # 01:10 + + with freeze_time(time): + async_fire_time_changed(hass, time) + await hass.async_block_till_done() + + # Sliding window will continue to erase the initial on period, so now it will only be on for 5 minutes + assert hass.states.get("sensor.sensor0").state == "0.08" + assert hass.states.get("sensor.sensor1").state == "8.3" + assert hass.states.get("sensor.sensor2").state == "1" + + time += timedelta(minutes=10) # 01:20 + + with freeze_time(time): + async_fire_time_changed(hass, time) + await hass.async_block_till_done() + + assert hass.states.get("sensor.sensor0").state == "0.0" + assert hass.states.get("sensor.sensor1").state == "0.0" + assert hass.states.get("sensor.sensor2").state == "0" + + async def test_does_not_work_into_the_future( recorder_mock: Recorder, hass: HomeAssistant ) -> None: @@ -1366,10 +1495,6 @@ async def test_measure_from_end_going_backwards( past_next_update = start_time + timedelta(minutes=30) with ( - patch( - "homeassistant.components.recorder.history.state_changes_during_period", - _fake_states, - ), freeze_time(past_next_update), ): async_fire_time_changed(hass, past_next_update) @@ -1526,29 +1651,10 @@ async def test_state_change_during_window_rollover( assert hass.states.get("sensor.sensor1").state == "11.98" - # One minute has passed and the time has now rolled over into a new day, resetting the recorder window. The sensor will then query the database for updates, - # and will see that the sensor is ON starting from midnight. + # One minute has passed and the time has now rolled over into a new day, resetting the recorder window. + # The sensor will be ON since midnight. t3 = t2 + timedelta(minutes=1) - - def _fake_states_t3(*args, **kwargs): - return { - "binary_sensor.state": [ - ha.State( - "binary_sensor.state", - "on", - last_changed=t3.replace(hour=0, minute=0, second=0, microsecond=0), - last_updated=t3.replace(hour=0, minute=0, second=0, microsecond=0), - ), - ] - } - - with ( - patch( - "homeassistant.components.recorder.history.state_changes_during_period", - _fake_states_t3, - ), - freeze_time(t3), - ): + with freeze_time(t3): # The sensor turns off around this time, before the sensor does its normal polled update. hass.states.async_set("binary_sensor.state", "off") await hass.async_block_till_done(wait_background_tasks=True) From 99b25efb674d50711a42034a724e32d324a1eb49 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Sun, 20 Apr 2025 20:20:23 +0200 Subject: [PATCH 46/70] Fix spelling of "off-grid" in `goodwe` (#143355) --- homeassistant/components/goodwe/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/goodwe/strings.json b/homeassistant/components/goodwe/strings.json index ec4ea80e22a..6348da45618 100644 --- a/homeassistant/components/goodwe/strings.json +++ b/homeassistant/components/goodwe/strings.json @@ -36,7 +36,7 @@ "name": "Inverter operation mode", "state": { "general": "General mode", - "off_grid": "Off grid mode", + "off_grid": "Off-grid mode", "backup": "Backup mode", "eco": "Eco mode", "peak_shaving": "Peak shaving mode", From d7f6db5efd24bd7ce855408697453ad126aff6b6 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Sun, 20 Apr 2025 20:20:43 +0200 Subject: [PATCH 47/70] Fix spelling of "off-grid" in `apsystems` (#143356) * Fix spelling of "off-grid" in `apsystems` * Update test_binary_sensor.ambr --- homeassistant/components/apsystems/strings.json | 2 +- tests/components/apsystems/snapshots/test_binary_sensor.ambr | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/apsystems/strings.json b/homeassistant/components/apsystems/strings.json index b3a10ca49a7..bdcd464ee9c 100644 --- a/homeassistant/components/apsystems/strings.json +++ b/homeassistant/components/apsystems/strings.json @@ -21,7 +21,7 @@ "entity": { "binary_sensor": { "off_grid_status": { - "name": "Off grid status" + "name": "Off-grid status" }, "dc_1_short_circuit_error_status": { "name": "DC 1 short circuit error status" diff --git a/tests/components/apsystems/snapshots/test_binary_sensor.ambr b/tests/components/apsystems/snapshots/test_binary_sensor.ambr index 381fc1864fc..d2e73347c83 100644 --- a/tests/components/apsystems/snapshots/test_binary_sensor.ambr +++ b/tests/components/apsystems/snapshots/test_binary_sensor.ambr @@ -120,7 +120,7 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Off grid status', + 'original_name': 'Off-grid status', 'platform': 'apsystems', 'previous_unique_id': None, 'supported_features': 0, @@ -133,7 +133,7 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'problem', - 'friendly_name': 'Mock Title Off grid status', + 'friendly_name': 'Mock Title Off-grid status', }), 'context': , 'entity_id': 'binary_sensor.mock_title_off_grid_status', From 26ea97cb442f8991221f7a4709b941471ecdf905 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Sun, 20 Apr 2025 20:21:06 +0200 Subject: [PATCH 48/70] Fix spelling of "off-grid" and "on-grid" in `teslemetry` (#143357) * Fix spelling of "off-grid" and "on-grid" in `teslemetry` * Update test_number.ambr --- homeassistant/components/teslemetry/strings.json | 10 +++++----- tests/components/teslemetry/snapshots/test_number.ambr | 4 ++-- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/teslemetry/strings.json b/homeassistant/components/teslemetry/strings.json index ff9bb5d595f..99a4b538639 100644 --- a/homeassistant/components/teslemetry/strings.json +++ b/homeassistant/components/teslemetry/strings.json @@ -363,7 +363,7 @@ "name": "Charge limit" }, "off_grid_vehicle_charging_reserve_percent": { - "name": "Off grid reserve" + "name": "Off-grid reserve" } }, "cover": { @@ -495,10 +495,10 @@ "name": "Island status", "state": { "island_status_unknown": "Unknown", - "on_grid": "On grid", - "off_grid": "Off grid", - "off_grid_intentional": "Off grid intentional", - "off_grid_unintentional": "Off grid unintentional" + "on_grid": "On-grid", + "off_grid": "Off-grid", + "off_grid_intentional": "Off-grid intentional", + "off_grid_unintentional": "Off-grid unintentional" } }, "load_power": { diff --git a/tests/components/teslemetry/snapshots/test_number.ambr b/tests/components/teslemetry/snapshots/test_number.ambr index 5ca9feb22f2..2c6705074f3 100644 --- a/tests/components/teslemetry/snapshots/test_number.ambr +++ b/tests/components/teslemetry/snapshots/test_number.ambr @@ -88,7 +88,7 @@ }), 'original_device_class': , 'original_icon': 'mdi:battery-unknown', - 'original_name': 'Off grid reserve', + 'original_name': 'Off-grid reserve', 'platform': 'teslemetry', 'previous_unique_id': None, 'supported_features': 0, @@ -101,7 +101,7 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'battery', - 'friendly_name': 'Energy Site Off grid reserve', + 'friendly_name': 'Energy Site Off-grid reserve', 'icon': 'mdi:battery-unknown', 'max': 100, 'min': 0, From 18cd389c77d53a1e1789d9a4557621c6ff4ad2a4 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Sun, 20 Apr 2025 20:21:29 +0200 Subject: [PATCH 49/70] Fix spelling of "off-grid" in `tessie` (#143358) * Fix spelling of "off-grid" in `tessie` * Update test_number.ambr --- homeassistant/components/tessie/strings.json | 2 +- tests/components/tessie/snapshots/test_number.ambr | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/tessie/strings.json b/homeassistant/components/tessie/strings.json index 4c2f1ebbb68..fa0c7f8c1f7 100644 --- a/homeassistant/components/tessie/strings.json +++ b/homeassistant/components/tessie/strings.json @@ -495,7 +495,7 @@ "name": "Speed limit" }, "off_grid_vehicle_charging_reserve_percent": { - "name": "Off grid reserve" + "name": "Off-grid reserve" } }, "update": { diff --git a/tests/components/tessie/snapshots/test_number.ambr b/tests/components/tessie/snapshots/test_number.ambr index 0e43695ca78..e865058c4a2 100644 --- a/tests/components/tessie/snapshots/test_number.ambr +++ b/tests/components/tessie/snapshots/test_number.ambr @@ -88,7 +88,7 @@ }), 'original_device_class': , 'original_icon': 'mdi:battery-unknown', - 'original_name': 'Off grid reserve', + 'original_name': 'Off-grid reserve', 'platform': 'tessie', 'previous_unique_id': None, 'supported_features': 0, @@ -101,7 +101,7 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'battery', - 'friendly_name': 'Energy Site Off grid reserve', + 'friendly_name': 'Energy Site Off-grid reserve', 'icon': 'mdi:battery-unknown', 'max': 100, 'min': 0, From 1ad60881cb538940ad2fc79133efacb293c84fa6 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Sun, 20 Apr 2025 20:21:51 +0200 Subject: [PATCH 50/70] Fix spelling of "off-grid" in `tesla_fleet` (#143359) * Fix spelling of "off-grid" in `tesla_fleet` * Update test_number.ambr --- homeassistant/components/tesla_fleet/strings.json | 2 +- tests/components/tesla_fleet/snapshots/test_number.ambr | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/tesla_fleet/strings.json b/homeassistant/components/tesla_fleet/strings.json index fcd2e07306f..04bad432919 100644 --- a/homeassistant/components/tesla_fleet/strings.json +++ b/homeassistant/components/tesla_fleet/strings.json @@ -199,7 +199,7 @@ "name": "Charge limit" }, "off_grid_vehicle_charging_reserve_percent": { - "name": "Off grid reserve" + "name": "Off-grid reserve" } }, "select": { diff --git a/tests/components/tesla_fleet/snapshots/test_number.ambr b/tests/components/tesla_fleet/snapshots/test_number.ambr index 1981544a024..a3fccf3a45a 100644 --- a/tests/components/tesla_fleet/snapshots/test_number.ambr +++ b/tests/components/tesla_fleet/snapshots/test_number.ambr @@ -88,7 +88,7 @@ }), 'original_device_class': , 'original_icon': 'mdi:battery-unknown', - 'original_name': 'Off grid reserve', + 'original_name': 'Off-grid reserve', 'platform': 'tesla_fleet', 'previous_unique_id': None, 'supported_features': 0, @@ -101,7 +101,7 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'battery', - 'friendly_name': 'Energy Site Off grid reserve', + 'friendly_name': 'Energy Site Off-grid reserve', 'icon': 'mdi:battery-unknown', 'max': 100, 'min': 0, From 2d30ae2bd9ba6e047fcd65526d601fd72c4e4af5 Mon Sep 17 00:00:00 2001 From: Adrien Cognee Date: Sun, 20 Apr 2025 21:18:31 +0200 Subject: [PATCH 51/70] Rename Cozytouch comfort preset modes in Overkiz (#143365) Rename cozytouch comfort preset modes --- homeassistant/components/overkiz/strings.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/overkiz/strings.json b/homeassistant/components/overkiz/strings.json index c2b9dd58743..d3f05f2b262 100644 --- a/homeassistant/components/overkiz/strings.json +++ b/homeassistant/components/overkiz/strings.json @@ -71,8 +71,8 @@ "state": { "auto": "[%key:common::state::auto%]", "manual": "[%key:common::state::manual%]", - "comfort-1": "Comfort 1", - "comfort-2": "Comfort 2", + "comfort-1": "Comfort -1°C", + "comfort-2": "Comfort -2°C", "drying": "Drying", "external": "External", "freeze": "Freeze", From 931161b00735642102f4a43fb0eca48e1ba10592 Mon Sep 17 00:00:00 2001 From: Adrien Cognee Date: Sun, 20 Apr 2025 21:18:47 +0200 Subject: [PATCH 52/70] Add missing icons to Cozytouch preset modes in Overkiz (#143364) Add missing to cozytouch preset modes --- homeassistant/components/overkiz/icons.json | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) create mode 100644 homeassistant/components/overkiz/icons.json diff --git a/homeassistant/components/overkiz/icons.json b/homeassistant/components/overkiz/icons.json new file mode 100644 index 00000000000..b955f7c77f8 --- /dev/null +++ b/homeassistant/components/overkiz/icons.json @@ -0,0 +1,20 @@ +{ + "entity": { + "climate": { + "overkiz": { + "state_attributes": { + "preset_mode": { + "state": { + "auto": "mdi:thermostat-auto", + "comfort-1": "mdi:thermometer", + "comfort-2": "mdi:thermometer-low", + "frost_protection": "mdi:snowflake", + "prog": "mdi:clock-outline", + "external": "mdi:remote" + } + } + } + } + } + } +} From e86bffdf89c397b00b3a8b03fc5aded7d564acaa Mon Sep 17 00:00:00 2001 From: Adrien Cognee Date: Sun, 20 Apr 2025 22:01:38 +0200 Subject: [PATCH 53/70] Set Cozytouch hvac action from regulation mode in Overkiz (#143363) Set cozytouch hvac action from regulation mode --- ...heater_with_adjustable_temperature_setpoint.py | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/homeassistant/components/overkiz/climate/atlantic_electrical_heater_with_adjustable_temperature_setpoint.py b/homeassistant/components/overkiz/climate/atlantic_electrical_heater_with_adjustable_temperature_setpoint.py index 93c7d03293b..041571f7b5f 100644 --- a/homeassistant/components/overkiz/climate/atlantic_electrical_heater_with_adjustable_temperature_setpoint.py +++ b/homeassistant/components/overkiz/climate/atlantic_electrical_heater_with_adjustable_temperature_setpoint.py @@ -13,6 +13,7 @@ from homeassistant.components.climate import ( PRESET_NONE, ClimateEntity, ClimateEntityFeature, + HVACAction, HVACMode, ) from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature @@ -56,6 +57,12 @@ OVERKIZ_TO_HVAC_MODE: dict[str, HVACMode] = { OverkizCommandParam.INTERNAL: HVACMode.AUTO, } +OVERKIZ_TO_HVAC_ACTION: dict[str, HVACAction] = { + OverkizCommandParam.STANDBY: HVACAction.IDLE, + OverkizCommandParam.INCREASE: HVACAction.HEATING, + OverkizCommandParam.NONE: HVACAction.OFF, +} + HVAC_MODE_TO_OVERKIZ = {v: k for k, v in OVERKIZ_TO_HVAC_MODE.items()} TEMPERATURE_SENSOR_DEVICE_INDEX = 2 @@ -102,6 +109,14 @@ class AtlanticElectricalHeaterWithAdjustableTemperatureSetpoint( OverkizCommand.SET_OPERATING_MODE, HVAC_MODE_TO_OVERKIZ[hvac_mode] ) + @property + def hvac_action(self) -> HVACAction: + """Return the current running hvac operation ie. heating, idle, off.""" + states = self.device.states + if (state := states[OverkizState.CORE_REGULATION_MODE]) and state.value_as_str: + return OVERKIZ_TO_HVAC_ACTION[state.value_as_str] + return HVACAction.OFF + @property def preset_mode(self) -> str | None: """Return the current preset mode, e.g., home, away, temp.""" From ee3ee5b1653dabbb81ef9d573f11fab1f5d8ac81 Mon Sep 17 00:00:00 2001 From: Simone Chemelli Date: Sun, 20 Apr 2025 23:56:09 +0200 Subject: [PATCH 54/70] Fix Vodafone Station config entry unload (#143371) --- .../components/vodafone_station/__init__.py | 2 -- tests/components/vodafone_station/conftest.py | 2 +- .../components/vodafone_station/test_init.py | 20 +++++++++++++++++++ 3 files changed, 21 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/vodafone_station/__init__.py b/homeassistant/components/vodafone_station/__init__.py index 9f118fe4fbd..b4ba5663ac2 100644 --- a/homeassistant/components/vodafone_station/__init__.py +++ b/homeassistant/components/vodafone_station/__init__.py @@ -3,7 +3,6 @@ from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME, Platform from homeassistant.core import HomeAssistant -from .const import DOMAIN from .coordinator import VodafoneConfigEntry, VodafoneStationRouter PLATFORMS = [Platform.BUTTON, Platform.DEVICE_TRACKER, Platform.SENSOR] @@ -36,7 +35,6 @@ async def async_unload_entry(hass: HomeAssistant, entry: VodafoneConfigEntry) -> coordinator = entry.runtime_data await coordinator.api.logout() await coordinator.api.close() - hass.data[DOMAIN].pop(entry.entry_id) return unload_ok diff --git a/tests/components/vodafone_station/conftest.py b/tests/components/vodafone_station/conftest.py index a065a1e8065..778d8fdaa41 100644 --- a/tests/components/vodafone_station/conftest.py +++ b/tests/components/vodafone_station/conftest.py @@ -5,7 +5,7 @@ from datetime import UTC, datetime from aiovodafone import VodafoneStationDevice import pytest -from homeassistant.components.vodafone_station import DOMAIN +from homeassistant.components.vodafone_station.const import DOMAIN from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME from .const import DEVICE_1_HOST, DEVICE_1_MAC, DEVICE_2_MAC diff --git a/tests/components/vodafone_station/test_init.py b/tests/components/vodafone_station/test_init.py index 12b3c3dce8f..053f0a95fe4 100644 --- a/tests/components/vodafone_station/test_init.py +++ b/tests/components/vodafone_station/test_init.py @@ -3,6 +3,8 @@ from unittest.mock import AsyncMock from homeassistant.components.device_tracker import CONF_CONSIDER_HOME +from homeassistant.components.vodafone_station.const import DOMAIN +from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType @@ -31,3 +33,21 @@ async def test_reload_config_entry_with_options( assert result["data"] == { CONF_CONSIDER_HOME: 37, } + + +async def test_unload_entry( + hass: HomeAssistant, + mock_vodafone_station_router: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test unloading the config entry.""" + await setup_integration(hass, mock_config_entry) + + assert len(hass.config_entries.async_entries(DOMAIN)) == 1 + assert mock_config_entry.state is ConfigEntryState.LOADED + + assert await hass.config_entries.async_unload(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry.state is ConfigEntryState.NOT_LOADED + assert not hass.data.get(DOMAIN) From da8339066b81aebe733c8d701878dad4c0c2e4b2 Mon Sep 17 00:00:00 2001 From: Retha Runolfsson <137745329+zerzhang@users.noreply.github.com> Date: Mon, 21 Apr 2025 18:07:03 +0800 Subject: [PATCH 55/70] Add light unit tests for switchbot (#140436) --- tests/components/switchbot/__init__.py | 25 ++++ tests/components/switchbot/test_light.py | 139 +++++++++++++++++++++++ 2 files changed, 164 insertions(+) create mode 100644 tests/components/switchbot/test_light.py diff --git a/tests/components/switchbot/__init__.py b/tests/components/switchbot/__init__.py index bb7f950b0da..80606fb45f0 100644 --- a/tests/components/switchbot/__init__.py +++ b/tests/components/switchbot/__init__.py @@ -463,3 +463,28 @@ HUMIDIFIER_SERVICE_INFO = BluetoothServiceInfoBleak( connectable=True, tx_power=-127, ) + + +WOSTRIP_SERVICE_INFO = BluetoothServiceInfoBleak( + name="WoStrip", + address="AA:BB:CC:DD:EE:FF", + manufacturer_data={ + 2409: b'\x84\xf7\x03\xb3?\xde\x04\xe4"\x0c\x00\x00\x00\x00\x00\x00' + }, + service_data={"00000d00-0000-1000-8000-00805f9b34fb": b"r\x00d"}, + service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"], + rssi=-60, + source="local", + advertisement=generate_advertisement_data( + local_name="WoStrip", + manufacturer_data={ + 2409: b'\x84\xf7\x03\xb3?\xde\x04\xe4"\x0c\x00\x00\x00\x00\x00\x00' + }, + service_data={"00000d00-0000-1000-8000-00805f9b34fb": b"r\x00d"}, + service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"], + ), + device=generate_ble_device("AA:BB:CC:DD:EE:FF", "WoStrip"), + time=0, + connectable=True, + tx_power=-127, +) diff --git a/tests/components/switchbot/test_light.py b/tests/components/switchbot/test_light.py new file mode 100644 index 00000000000..ef46017e9ae --- /dev/null +++ b/tests/components/switchbot/test_light.py @@ -0,0 +1,139 @@ +"""Test the switchbot lights.""" + +from collections.abc import Callable +from typing import Any +from unittest.mock import AsyncMock, patch + +import pytest +from switchbot import ColorMode as switchbotColorMode + +from homeassistant.components.light import ( + ATTR_BRIGHTNESS, + ATTR_COLOR_TEMP_KELVIN, + ATTR_RGB_COLOR, + DOMAIN as LIGHT_DOMAIN, + SERVICE_TURN_OFF, + SERVICE_TURN_ON, +) +from homeassistant.const import ATTR_ENTITY_ID +from homeassistant.core import HomeAssistant + +from . import WOSTRIP_SERVICE_INFO + +from tests.common import MockConfigEntry +from tests.components.bluetooth import inject_bluetooth_service_info + + +@pytest.mark.parametrize( + ( + "service", + "service_data", + "mock_method", + "expected_args", + "color_modes", + "color_mode", + ), + [ + ( + SERVICE_TURN_OFF, + {}, + "turn_off", + (), + {switchbotColorMode.RGB}, + switchbotColorMode.RGB, + ), + ( + SERVICE_TURN_ON, + {}, + "turn_on", + (), + {switchbotColorMode.RGB}, + switchbotColorMode.RGB, + ), + ( + SERVICE_TURN_ON, + {ATTR_BRIGHTNESS: 128}, + "set_brightness", + (round(128 / 255 * 100),), + {switchbotColorMode.RGB}, + switchbotColorMode.RGB, + ), + ( + SERVICE_TURN_ON, + {ATTR_RGB_COLOR: (255, 0, 0)}, + "set_rgb", + (round(255 / 255 * 100), 255, 0, 0), + {switchbotColorMode.RGB}, + switchbotColorMode.RGB, + ), + ( + SERVICE_TURN_ON, + {ATTR_COLOR_TEMP_KELVIN: 4000}, + "set_color_temp", + (100, 4000), + {switchbotColorMode.COLOR_TEMP}, + switchbotColorMode.COLOR_TEMP, + ), + ], +) +async def test_light_strip_services( + hass: HomeAssistant, + mock_entry_factory: Callable[[str], MockConfigEntry], + service: str, + service_data: dict, + mock_method: str, + expected_args: Any, + color_modes: set | None, + color_mode: switchbotColorMode | None, +) -> None: + """Test all SwitchBot light strip services with proper parameters.""" + inject_bluetooth_service_info(hass, WOSTRIP_SERVICE_INFO) + + entry = mock_entry_factory(sensor_type="light_strip") + entry.add_to_hass(hass) + entity_id = "light.test_name" + + with ( + patch("switchbot.SwitchbotLightStrip.color_modes", new=color_modes), + patch("switchbot.SwitchbotLightStrip.color_mode", new=color_mode), + patch( + "switchbot.SwitchbotLightStrip.turn_on", + new=AsyncMock(return_value=True), + ) as mock_turn_on, + patch( + "switchbot.SwitchbotLightStrip.turn_off", + new=AsyncMock(return_value=True), + ) as mock_turn_off, + patch( + "switchbot.SwitchbotLightStrip.set_brightness", + new=AsyncMock(return_value=True), + ) as mock_set_brightness, + patch( + "switchbot.SwitchbotLightStrip.set_rgb", + new=AsyncMock(return_value=True), + ) as mock_set_rgb, + patch( + "switchbot.SwitchbotLightStrip.set_color_temp", + new=AsyncMock(return_value=True), + ) as mock_set_color_temp, + patch("switchbot.SwitchbotLightStrip.update", new=AsyncMock(return_value=None)), + ): + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + await hass.services.async_call( + LIGHT_DOMAIN, + service, + {**service_data, ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + + mock_map = { + "turn_off": mock_turn_off, + "turn_on": mock_turn_on, + "set_brightness": mock_set_brightness, + "set_rgb": mock_set_rgb, + "set_color_temp": mock_set_color_temp, + } + mock_instance = mock_map[mock_method] + mock_instance.assert_awaited_once_with(*expected_args) From 274a507bc4ca69b575e31ddfa621b58cff7f70e0 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 21 Apr 2025 00:42:21 -1000 Subject: [PATCH 56/70] Bump aiohttp to 3.11.18 (#143392) changelog: https://github.com/aio-libs/aiohttp/compare/v3.11.17...v3.11.18 --- 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 c2ec566a342..63b9a9fa91f 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -6,7 +6,7 @@ aiodns==3.2.0 aiohasupervisor==0.3.1b1 aiohttp-asyncmdnsresolver==0.1.1 aiohttp-fast-zlib==0.2.3 -aiohttp==3.11.17 +aiohttp==3.11.18 aiohttp_cors==0.7.0 aiousbwatcher==1.1.1 aiozoneinfo==0.2.3 diff --git a/pyproject.toml b/pyproject.toml index 7ec4b80f019..054a3da615d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -28,7 +28,7 @@ dependencies = [ # change behavior based on presence of supervisor. Deprecated with #127228 # Lib can be removed with 2025.11 "aiohasupervisor==0.3.1b1", - "aiohttp==3.11.17", + "aiohttp==3.11.18", "aiohttp_cors==0.7.0", "aiohttp-fast-zlib==0.2.3", "aiohttp-asyncmdnsresolver==0.1.1", diff --git a/requirements.txt b/requirements.txt index a77e47fcf9a..aa5ecb3487c 100644 --- a/requirements.txt +++ b/requirements.txt @@ -5,7 +5,7 @@ # Home Assistant Core aiodns==3.2.0 aiohasupervisor==0.3.1b1 -aiohttp==3.11.17 +aiohttp==3.11.18 aiohttp_cors==0.7.0 aiohttp-fast-zlib==0.2.3 aiohttp-asyncmdnsresolver==0.1.1 From 51eb4770a7e735f90f78038d08c643aa0dd14864 Mon Sep 17 00:00:00 2001 From: karwosts <32912880+karwosts@users.noreply.github.com> Date: Mon, 21 Apr 2025 03:52:26 -0700 Subject: [PATCH 57/70] Use config_entry selector for reload_config_entry (#143370) --- homeassistant/components/homeassistant/services.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/homeassistant/services.yaml b/homeassistant/components/homeassistant/services.yaml index 897b7d50e31..372f4fa9955 100644 --- a/homeassistant/components/homeassistant/services.yaml +++ b/homeassistant/components/homeassistant/services.yaml @@ -61,7 +61,7 @@ reload_config_entry: required: false example: 8955375327824e14ba89e4b29cc3ec9a selector: - text: + config_entry: save_persistent_states: From 694c768666b42c8ecb09f535be1f47f12cf4b6e7 Mon Sep 17 00:00:00 2001 From: mdcdr Date: Mon, 21 Apr 2025 13:45:57 +0200 Subject: [PATCH 58/70] Fix utility_meter wrong/old value on reset (#142951) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Erwin Douna Co-authored-by: Abílio Costa --- .../components/utility_meter/sensor.py | 4 ++-- tests/components/utility_meter/test_sensor.py | 18 ++++++++++++++++++ 2 files changed, 20 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/utility_meter/sensor.py b/homeassistant/components/utility_meter/sensor.py index 425dfa2c3fd..cda538386c1 100644 --- a/homeassistant/components/utility_meter/sensor.py +++ b/homeassistant/components/utility_meter/sensor.py @@ -577,10 +577,10 @@ class UtilityMeterSensor(RestoreSensor): async def _async_reset_meter(self, event): """Reset the utility meter status.""" - await self._program_reset() - await self.async_reset_meter(self._tariff_entity) + await self._program_reset() + async def async_reset_meter(self, entity_id): """Reset meter.""" if self._tariff_entity is not None and self._tariff_entity != entity_id: diff --git a/tests/components/utility_meter/test_sensor.py b/tests/components/utility_meter/test_sensor.py index c671969c5ac..2de2ee553b3 100644 --- a/tests/components/utility_meter/test_sensor.py +++ b/tests/components/utility_meter/test_sensor.py @@ -43,6 +43,7 @@ from homeassistant.const import ( ) from homeassistant.core import CoreState, HomeAssistant, State from homeassistant.helpers import device_registry as dr, entity_registry as er +from homeassistant.helpers.event import async_track_state_change_event from homeassistant.setup import async_setup_component from homeassistant.util import dt as dt_util @@ -1637,8 +1638,21 @@ async def _test_self_reset( now += timedelta(seconds=30) with freeze_time(now): + # Listen for events and check that state in the first event after reset is actually 0, issue #142053 + events = [] + + async def handle_energy_bill_event(event): + events.append(event) + + unsub = async_track_state_change_event( + hass, + "sensor.energy_bill", + handle_energy_bill_event, + ) + async_fire_time_changed(hass, now) await hass.async_block_till_done() + unsub() hass.states.async_set( entity_id, 6, @@ -1654,6 +1668,10 @@ async def _test_self_reset( state.attributes.get("last_reset") == dt_util.as_utc(now).isoformat() ) # last_reset is kept in UTC assert state.state == "3" + # In first event state should be 0 + assert len(events) == 2 + assert events[0].data.get("new_state").state == "0" + assert events[1].data.get("new_state").state == "0" else: assert state.attributes.get("last_period") == "0" assert state.state == "5" From 8fa48a9781d676e4992d7889f6e44ae72e912bd4 Mon Sep 17 00:00:00 2001 From: Simon Lamon <32477463+silamon@users.noreply.github.com> Date: Mon, 21 Apr 2025 13:58:47 +0200 Subject: [PATCH 59/70] Sync random sensor device classes (#143368) --- homeassistant/components/random/strings.json | 2 ++ 1 file changed, 2 insertions(+) diff --git a/homeassistant/components/random/strings.json b/homeassistant/components/random/strings.json index e5c5543e39f..bacd6dd5a17 100644 --- a/homeassistant/components/random/strings.json +++ b/homeassistant/components/random/strings.json @@ -84,8 +84,10 @@ "options": { "apparent_power": "[%key:component::sensor::entity_component::apparent_power::name%]", "aqi": "[%key:component::sensor::entity_component::aqi::name%]", + "area": "[%key:component::sensor::entity_component::area::name%]", "atmospheric_pressure": "[%key:component::sensor::entity_component::atmospheric_pressure::name%]", "battery": "[%key:component::sensor::entity_component::battery::name%]", + "blood_glucose_concentration": "[%key:component::sensor::entity_component::blood_glucose_concentration::name%]", "carbon_dioxide": "[%key:component::sensor::entity_component::carbon_dioxide::name%]", "carbon_monoxide": "[%key:component::sensor::entity_component::carbon_monoxide::name%]", "conductivity": "[%key:component::sensor::entity_component::conductivity::name%]", From 7ea8827e691117d74560bfd049e940ecd8d2b242 Mon Sep 17 00:00:00 2001 From: Simone Chemelli Date: Mon, 21 Apr 2025 14:11:51 +0200 Subject: [PATCH 60/70] Fix typos in UptimeRobot tests (#143397) --- .../components/uptimerobot/quality_scale.yaml | 4 +--- .../uptimerobot/test_binary_sensor.py | 4 ++-- .../components/uptimerobot/test_config_flow.py | 18 ++++++++---------- tests/components/uptimerobot/test_init.py | 1 - 4 files changed, 11 insertions(+), 16 deletions(-) diff --git a/homeassistant/components/uptimerobot/quality_scale.yaml b/homeassistant/components/uptimerobot/quality_scale.yaml index 1ab2c117483..d0647da7682 100644 --- a/homeassistant/components/uptimerobot/quality_scale.yaml +++ b/homeassistant/components/uptimerobot/quality_scale.yaml @@ -41,9 +41,7 @@ rules: log-when-unavailable: done parallel-updates: done reauthentication-flow: done - test-coverage: - status: todo - comment: recheck typos + test-coverage: done # Gold devices: done diff --git a/tests/components/uptimerobot/test_binary_sensor.py b/tests/components/uptimerobot/test_binary_sensor.py index 4b27ab5ff05..3de9b9ec399 100644 --- a/tests/components/uptimerobot/test_binary_sensor.py +++ b/tests/components/uptimerobot/test_binary_sensor.py @@ -34,8 +34,8 @@ async def test_presentation(hass: HomeAssistant) -> None: assert entity.attributes["target"] == MOCK_UPTIMEROBOT_MONITOR["url"] -async def test_unaviable_on_update_failure(hass: HomeAssistant) -> None: - """Test entity unaviable on update failure.""" +async def test_unavailable_on_update_failure(hass: HomeAssistant) -> None: + """Test entity unavailable on update failure.""" await setup_uptimerobot_integration(hass) entity = hass.states.get(UPTIMEROBOT_BINARY_SENSOR_TEST_ENTITY) diff --git a/tests/components/uptimerobot/test_config_flow.py b/tests/components/uptimerobot/test_config_flow.py index 3ba5ad696a6..c7ae6a5d772 100644 --- a/tests/components/uptimerobot/test_config_flow.py +++ b/tests/components/uptimerobot/test_config_flow.py @@ -24,8 +24,8 @@ from .common import ( from tests.common import MockConfigEntry -async def test_form(hass: HomeAssistant) -> None: - """Test we get the form.""" +async def test_user(hass: HomeAssistant) -> None: + """Test user flow.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} @@ -56,8 +56,8 @@ async def test_form(hass: HomeAssistant) -> None: assert len(mock_setup_entry.mock_calls) == 1 -async def test_form_read_only(hass: HomeAssistant) -> None: - """Test we get the form.""" +async def test_user_key_read_only(hass: HomeAssistant) -> None: + """Test user flow with read only key.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} @@ -87,8 +87,8 @@ async def test_form_read_only(hass: HomeAssistant) -> None: (UptimeRobotAuthenticationException, "invalid_api_key"), ], ) -async def test_form_exception_thrown(hass: HomeAssistant, exception, error_key) -> None: - """Test that we handle exceptions.""" +async def test_exception_thrown(hass: HomeAssistant, exception, error_key) -> None: + """Test user flow throwing exceptions.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) @@ -106,10 +106,8 @@ async def test_form_exception_thrown(hass: HomeAssistant, exception, error_key) assert result2["errors"]["base"] == error_key -async def test_form_api_error( - hass: HomeAssistant, caplog: pytest.LogCaptureFixture -) -> None: - """Test we handle unexpected error.""" +async def test_api_error(hass: HomeAssistant, caplog: pytest.LogCaptureFixture) -> None: + """Test expected API error is catch.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) diff --git a/tests/components/uptimerobot/test_init.py b/tests/components/uptimerobot/test_init.py index 187178de78d..435b0737c6d 100644 --- a/tests/components/uptimerobot/test_init.py +++ b/tests/components/uptimerobot/test_init.py @@ -239,7 +239,6 @@ async def test_device_management( freezer.tick(COORDINATOR_UPDATE_INTERVAL) async_fire_time_changed(hass) await hass.async_block_till_done() - await hass.async_block_till_done() devices = dr.async_entries_for_config_entry(device_registry, mock_entry.entry_id) assert len(devices) == 1 From bb73ecc1f4d7fe2d997b1d1a8424f0076ab61d61 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 21 Apr 2025 03:37:10 -1000 Subject: [PATCH 61/70] Restore service call performance by avoiding expensive runtime cast (#143378) Improve service call performance by avoiding expensive runtime type checking Most of the overhead here was casting --- homeassistant/helpers/config_validation.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/homeassistant/helpers/config_validation.py b/homeassistant/helpers/config_validation.py index 5c1a7c99565..655913558d6 100644 --- a/homeassistant/helpers/config_validation.py +++ b/homeassistant/helpers/config_validation.py @@ -21,7 +21,7 @@ from socket import ( # type: ignore[attr-defined] # private, not in typeshed _GLOBAL_DEFAULT_TIMEOUT, ) import threading -from typing import Any, cast, overload +from typing import TYPE_CHECKING, Any, cast, overload from urllib.parse import urlparse from uuid import UUID @@ -355,7 +355,13 @@ def ensure_list[_T](value: _T | None) -> list[_T] | list[Any]: """Wrap value in list if it is not one.""" if value is None: return [] - return cast(list[_T], value) if isinstance(value, list) else [value] + if isinstance(value, list): + if TYPE_CHECKING: + # https://github.com/home-assistant/core/pull/71960 + # cast with a type variable is still slow. + return cast(list[_T], value) + return value # type: ignore[unreachable] + return [value] def entity_id(value: Any) -> str: From 352ef0d009ea2a786e3f6ca66f09a5f2db66b590 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 21 Apr 2025 03:38:29 -1000 Subject: [PATCH 62/70] Correct handling of entities with empty name for ESPHome devices (#143366) Correct handling of empty name for ESPHome devices If the name was set to "", ESPHome should treat this as if the name is empty. Since protobuf treats empty fields as "" we need to handle this as `None` internally as otherwise it leads to friendly names like "Friendly Name " with a trailing space and unexpected entity_id formats fixes #132532 --- homeassistant/components/esphome/entity.py | 18 ++++++++-- tests/components/esphome/test_entity.py | 38 ++++++++++++++++++++++ 2 files changed, 54 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/esphome/entity.py b/homeassistant/components/esphome/entity.py index 313785fd2df..b442eaebb65 100644 --- a/homeassistant/components/esphome/entity.py +++ b/homeassistant/components/esphome/entity.py @@ -224,7 +224,16 @@ class EsphomeEntity(Entity, Generic[_InfoT, _StateT]): self._attr_device_info = DeviceInfo( connections={(dr.CONNECTION_NETWORK_MAC, device_info.mac_address)} ) - self.entity_id = f"{domain}.{device_info.name}_{entity_info.object_id}" + if entity_info.name: + self.entity_id = f"{domain}.{device_info.name}_{entity_info.object_id}" + else: + # https://github.com/home-assistant/core/issues/132532 + # If name is not set, ESPHome will use the sanitized friendly name + # as the name, however we want to use the original object_id + # as the entity_id before it is sanitized since the sanitizer + # is not utf-8 aware. In this case, its always going to be + # an empty string so we drop the object_id. + self.entity_id = f"{domain}.{device_info.name}" async def async_added_to_hass(self) -> None: """Register callbacks.""" @@ -260,7 +269,12 @@ class EsphomeEntity(Entity, Generic[_InfoT, _StateT]): self._static_info = static_info self._attr_unique_id = build_unique_id(device_info.mac_address, static_info) self._attr_entity_registry_enabled_default = not static_info.disabled_by_default - self._attr_name = static_info.name + # https://github.com/home-assistant/core/issues/132532 + # If the name is "", we need to set it to None since otherwise + # the friendly_name will be "{friendly_name} " with a trailing + # space. ESPHome uses protobuf under the hood, and an empty field + # gets a default value of "". + self._attr_name = static_info.name if static_info.name else None if entity_category := static_info.entity_category: self._attr_entity_category = ENTITY_CATEGORIES.from_esphome(entity_category) else: diff --git a/tests/components/esphome/test_entity.py b/tests/components/esphome/test_entity.py index 5c82337e71b..290b1871cd7 100644 --- a/tests/components/esphome/test_entity.py +++ b/tests/components/esphome/test_entity.py @@ -17,6 +17,7 @@ from aioesphomeapi import ( ) from homeassistant.const import ( + ATTR_FRIENDLY_NAME, ATTR_RESTORED, EVENT_HOMEASSISTANT_STOP, STATE_OFF, @@ -503,3 +504,40 @@ async def test_esphome_device_without_friendly_name( state = hass.states.get("binary_sensor.test_mybinary_sensor") assert state is not None assert state.state == STATE_ON + + +async def test_entity_without_name_device_with_friendly_name( + hass: HomeAssistant, + mock_client: APIClient, + hass_storage: dict[str, Any], + mock_esphome_device: Callable[ + [APIClient, list[EntityInfo], list[UserService], list[EntityState]], + Awaitable[MockESPHomeDevice], + ], +) -> None: + """Test name and entity_id for a device a friendly name and an entity without a name.""" + entity_info = [ + BinarySensorInfo( + object_id="mybinary_sensor", + key=1, + name="", + unique_id="my_binary_sensor", + ), + ] + states = [ + BinarySensorState(key=1, state=True, missing_state=False), + ] + user_service = [] + await mock_esphome_device( + mock_client=mock_client, + entity_info=entity_info, + user_service=user_service, + states=states, + device_info={"friendly_name": "The Best Mixer", "name": "mixer"}, + ) + state = hass.states.get("binary_sensor.mixer") + assert state is not None + assert state.state == STATE_ON + # Make sure we have set the name to `None` as otherwise + # the friendly_name will be "The Best Mixer " + assert state.attributes[ATTR_FRIENDLY_NAME] == "The Best Mixer" From 6698b3a1dcc819aae85977102aabd66523b032a8 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 21 Apr 2025 03:41:15 -1000 Subject: [PATCH 63/70] Improve ESPHome abort messages for already-configured devices (#143289) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Improve ESPHome abort messages for already-configured devices Users often struggle to identify which ESPHome device is already configured—especially when replacing a device or renaming an existing one. This PR improves the abort messages to include more helpful details, so users can pinpoint the conflicting device without needing to dig through the `core.config_entries` file manually. * Update homeassistant/components/esphome/strings.json --- .../components/esphome/config_flow.py | 35 +++++++++-- homeassistant/components/esphome/strings.json | 2 + tests/components/esphome/test_config_flow.py | 63 ++++++++++++++++--- tests/components/esphome/test_manager.py | 14 ++++- 4 files changed, 98 insertions(+), 16 deletions(-) diff --git a/homeassistant/components/esphome/config_flow.py b/homeassistant/components/esphome/config_flow.py index 2b1babfc0ba..72d670ee029 100644 --- a/homeassistant/components/esphome/config_flow.py +++ b/homeassistant/components/esphome/config_flow.py @@ -306,7 +306,32 @@ class EsphomeFlowHandler(ConfigFlow, domain=DOMAIN): updates[CONF_HOST] = host if port is not None: updates[CONF_PORT] = port - self._abort_if_unique_id_configured(updates=updates) + self._abort_unique_id_configured_with_details(updates=updates) + + @callback + def _abort_unique_id_configured_with_details(self, updates: dict[str, Any]) -> None: + """Abort if unique_id is already configured with details.""" + assert self.unique_id is not None + if not ( + conflict_entry := self.hass.config_entries.async_entry_for_domain_unique_id( + self.handler, self.unique_id + ) + ): + return + assert conflict_entry.unique_id is not None + if updates: + error = "already_configured_updates" + else: + error = "already_configured_detailed" + self._abort_if_unique_id_configured( + updates=updates, + error=error, + description_placeholders={ + "title": conflict_entry.title, + "name": conflict_entry.data.get(CONF_DEVICE_NAME, "unknown"), + "mac": format_mac(conflict_entry.unique_id), + }, + ) async def async_step_mqtt( self, discovery_info: MqttServiceInfo @@ -341,7 +366,7 @@ class EsphomeFlowHandler(ConfigFlow, domain=DOMAIN): # Check if already configured await self.async_set_unique_id(mac_address) - self._abort_if_unique_id_configured( + self._abort_unique_id_configured_with_details( updates={CONF_HOST: self._host, CONF_PORT: self._port} ) @@ -479,7 +504,7 @@ class EsphomeFlowHandler(ConfigFlow, domain=DOMAIN): data=self._reauth_entry.data | self._async_make_config_data(), ) assert self._host is not None - self._abort_if_unique_id_configured( + self._abort_unique_id_configured_with_details( updates={ CONF_HOST: self._host, CONF_PORT: self._port, @@ -510,7 +535,7 @@ class EsphomeFlowHandler(ConfigFlow, domain=DOMAIN): if not ( unique_id_matches := (self.unique_id == self._reconfig_entry.unique_id) ): - self._abort_if_unique_id_configured( + self._abort_unique_id_configured_with_details( updates={ CONF_HOST: self._host, CONF_PORT: self._port, @@ -640,7 +665,7 @@ class EsphomeFlowHandler(ConfigFlow, domain=DOMAIN): mac_address = format_mac(self._device_info.mac_address) await self.async_set_unique_id(mac_address, raise_on_progress=False) if self.source not in (SOURCE_REAUTH, SOURCE_RECONFIGURE): - self._abort_if_unique_id_configured( + self._abort_unique_id_configured_with_details( updates={ CONF_HOST: self._host, CONF_PORT: self._port, diff --git a/homeassistant/components/esphome/strings.json b/homeassistant/components/esphome/strings.json index 6c10a2e5fe8..b95537e448e 100644 --- a/homeassistant/components/esphome/strings.json +++ b/homeassistant/components/esphome/strings.json @@ -2,6 +2,8 @@ "config": { "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", + "already_configured_detailed": "A device `{name}`, with MAC address `{mac}` is already configured as `{title}`.", + "already_configured_updates": "A device `{name}`, with MAC address `{mac}` is already configured as `{title}`; the existing configuration will be updated with the validated data.", "already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]", "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", "mdns_missing_mac": "Missing MAC address in mDNS properties.", diff --git a/tests/components/esphome/test_config_flow.py b/tests/components/esphome/test_config_flow.py index 9d400ba618b..19a42792ec2 100644 --- a/tests/components/esphome/test_config_flow.py +++ b/tests/components/esphome/test_config_flow.py @@ -119,7 +119,12 @@ async def test_user_connection_updates_host( data={CONF_HOST: "127.0.0.1", CONF_PORT: 80}, ) assert result["type"] is FlowResultType.ABORT - assert result["reason"] == "already_configured" + assert result["reason"] == "already_configured_updates" + assert result["description_placeholders"] == { + "title": "Mock Title", + "name": "unknown", + "mac": "11:22:33:44:55:aa", + } assert entry.data[CONF_HOST] == "127.0.0.1" @@ -173,7 +178,12 @@ async def test_user_sets_unique_id( {CONF_HOST: "127.0.0.1", CONF_PORT: 6053}, ) assert result["type"] is FlowResultType.ABORT - assert result["reason"] == "already_configured" + assert result["reason"] == "already_configured_updates" + assert result["description_placeholders"] == { + "title": "test", + "name": "test", + "mac": "11:22:33:44:55:aa", + } @pytest.mark.usefixtures("mock_zeroconf") @@ -645,7 +655,12 @@ async def test_discovery_already_configured( ) assert result["type"] is FlowResultType.ABORT - assert result["reason"] == "already_configured" + assert result["reason"] == "already_configured_updates" + assert result["description_placeholders"] == { + "title": "Mock Title", + "name": "unknown", + "mac": "11:22:33:44:55:aa", + } async def test_discovery_duplicate_data( @@ -701,7 +716,12 @@ async def test_discovery_updates_unique_id( ) assert result["type"] is FlowResultType.ABORT - assert result["reason"] == "already_configured" + assert result["reason"] == "already_configured_updates" + assert result["description_placeholders"] == { + "title": "Mock Title", + "name": "unknown", + "mac": "11:22:33:44:55:aa", + } assert entry.unique_id == "11:22:33:44:55:aa" @@ -1159,7 +1179,12 @@ async def test_discovery_dhcp_updates_host( ) assert result["type"] is FlowResultType.ABORT - assert result["reason"] == "already_configured" + assert result["reason"] == "already_configured_updates" + assert result["description_placeholders"] == { + "title": "Mock Title", + "name": "unknown", + "mac": "11:22:33:44:55:aa", + } assert entry.data[CONF_HOST] == "192.168.43.184" @@ -1188,7 +1213,12 @@ async def test_discovery_dhcp_does_not_update_host_wrong_mac( ) assert result["type"] is FlowResultType.ABORT - assert result["reason"] == "already_configured" + assert result["reason"] == "already_configured_detailed" + assert result["description_placeholders"] == { + "title": "Mock Title", + "name": "unknown", + "mac": "11:22:33:44:55:aa", + } # Mac was wrong, should not update assert entry.data[CONF_HOST] == "192.168.43.183" @@ -1217,7 +1247,12 @@ async def test_discovery_dhcp_does_not_update_host_wrong_mac_bad_key( ) assert result["type"] is FlowResultType.ABORT - assert result["reason"] == "already_configured" + assert result["reason"] == "already_configured_detailed" + assert result["description_placeholders"] == { + "title": "Mock Title", + "name": "unknown", + "mac": "11:22:33:44:55:aa", + } # Mac was wrong, should not update assert entry.data[CONF_HOST] == "192.168.43.183" @@ -1246,7 +1281,12 @@ async def test_discovery_dhcp_does_not_update_host_missing_mac_bad_key( ) assert result["type"] is FlowResultType.ABORT - assert result["reason"] == "already_configured" + assert result["reason"] == "already_configured_detailed" + assert result["description_placeholders"] == { + "title": "Mock Title", + "name": "unknown", + "mac": "11:22:33:44:55:aa", + } # Mac was missing, should not update assert entry.data[CONF_HOST] == "192.168.43.183" @@ -1999,7 +2039,12 @@ async def test_reconfig_mac_used_by_other_entry( ) assert result["type"] is FlowResultType.ABORT - assert result["reason"] == "already_configured" + assert result["reason"] == "already_configured_updates" + assert result["description_placeholders"] == { + "title": "Mock Title", + "name": "test4", + "mac": "11:22:33:44:55:bb", + } @pytest.mark.usefixtures("mock_zeroconf", "mock_setup_entry") diff --git a/tests/components/esphome/test_manager.py b/tests/components/esphome/test_manager.py index 12ae58a8240..aa4ca665602 100644 --- a/tests/components/esphome/test_manager.py +++ b/tests/components/esphome/test_manager.py @@ -743,7 +743,12 @@ async def test_connection_aborted_wrong_device( ) assert result["type"] is FlowResultType.ABORT - assert result["reason"] == "already_configured" + assert result["reason"] == "already_configured_updates" + assert result["description_placeholders"] == { + "title": "Mock Title", + "name": "test", + "mac": "11:22:33:44:55:aa", + } assert entry.data[CONF_HOST] == "192.168.43.184" await hass.async_block_till_done() assert len(new_info.mock_calls) == 2 @@ -812,7 +817,12 @@ async def test_connection_aborted_wrong_device_same_name( ) assert result["type"] is FlowResultType.ABORT - assert result["reason"] == "already_configured" + assert result["reason"] == "already_configured_updates" + assert result["description_placeholders"] == { + "title": "Mock Title", + "name": "test", + "mac": "11:22:33:44:55:aa", + } assert entry.data[CONF_HOST] == "192.168.43.184" await hass.async_block_till_done() assert len(new_info.mock_calls) == 2 From 4b8447bc82d07564ed61def1fe2d9b2d7841540d Mon Sep 17 00:00:00 2001 From: Simone Chemelli Date: Mon, 21 Apr 2025 16:05:44 +0200 Subject: [PATCH 64/70] Move quality scale to bronze for UptimeRobot (#143399) --- homeassistant/components/uptimerobot/manifest.json | 1 + homeassistant/components/uptimerobot/quality_scale.yaml | 4 +--- script/hassfest/quality_scale.py | 1 - 3 files changed, 2 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/uptimerobot/manifest.json b/homeassistant/components/uptimerobot/manifest.json index 67e57f46986..6fe8083ffc6 100644 --- a/homeassistant/components/uptimerobot/manifest.json +++ b/homeassistant/components/uptimerobot/manifest.json @@ -6,5 +6,6 @@ "documentation": "https://www.home-assistant.io/integrations/uptimerobot", "iot_class": "cloud_polling", "loggers": ["pyuptimerobot"], + "quality_scale": "bronze", "requirements": ["pyuptimerobot==22.2.0"] } diff --git a/homeassistant/components/uptimerobot/quality_scale.yaml b/homeassistant/components/uptimerobot/quality_scale.yaml index d0647da7682..43076320b8f 100644 --- a/homeassistant/components/uptimerobot/quality_scale.yaml +++ b/homeassistant/components/uptimerobot/quality_scale.yaml @@ -6,9 +6,7 @@ rules: appropriate-polling: done brands: done common-modules: done - config-flow-test-coverage: - status: todo - comment: fix name and docstring + config-flow-test-coverage: done config-flow: done dependency-transparency: done docs-actions: diff --git a/script/hassfest/quality_scale.py b/script/hassfest/quality_scale.py index 5885b4acb1f..42c7f08a788 100644 --- a/script/hassfest/quality_scale.py +++ b/script/hassfest/quality_scale.py @@ -2133,7 +2133,6 @@ INTEGRATIONS_WITHOUT_SCALE = [ "upcloud", "upnp", "uptime", - "uptimerobot", "usb", "usgs_earthquakes_feed", "utility_meter", From ba6ce28d3c068be4a0c949e879f742603f2dd72b Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 21 Apr 2025 04:25:04 -1000 Subject: [PATCH 65/70] Add DHCP discovery subscribe websocket API (#143106) * Add DHCP discovery subscribe websocket API * fix circular import * fixes * fixes * fixes * reduce * reduce * reduce * fix tests * fix tests * rework * tests * reduce number of lines changed * reduce --- homeassistant/components/dhcp/__init__.py | 85 +++---- homeassistant/components/dhcp/const.py | 5 + homeassistant/components/dhcp/helpers.py | 37 +++ homeassistant/components/dhcp/models.py | 43 ++++ .../components/dhcp/websocket_api.py | 63 +++++ tests/components/dhcp/test_init.py | 231 +++++++++--------- tests/components/dhcp/test_websocket_api.py | 75 ++++++ 7 files changed, 381 insertions(+), 158 deletions(-) create mode 100644 homeassistant/components/dhcp/helpers.py create mode 100644 homeassistant/components/dhcp/models.py create mode 100644 homeassistant/components/dhcp/websocket_api.py create mode 100644 tests/components/dhcp/test_websocket_api.py diff --git a/homeassistant/components/dhcp/__init__.py b/homeassistant/components/dhcp/__init__.py index a11a0b262b0..76d11f22424 100644 --- a/homeassistant/components/dhcp/__init__.py +++ b/homeassistant/components/dhcp/__init__.py @@ -4,7 +4,6 @@ from __future__ import annotations import asyncio from collections.abc import Callable -from dataclasses import dataclass from datetime import timedelta from fnmatch import translate from functools import lru_cache, partial @@ -66,13 +65,12 @@ from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo as _DhcpServ from homeassistant.helpers.typing import ConfigType from homeassistant.loader import DHCPMatcher, async_get_dhcp -from .const import DOMAIN +from . import websocket_api +from .const import DOMAIN, HOSTNAME, IP_ADDRESS, MAC_ADDRESS +from .models import DATA_DHCP, DHCPAddressData, DHCPData, DhcpMatchers CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN) -HOSTNAME: Final = "hostname" -MAC_ADDRESS: Final = "macaddress" -IP_ADDRESS: Final = "ip" REGISTERED_DEVICES: Final = "registered_devices" SCAN_INTERVAL = timedelta(minutes=60) @@ -87,15 +85,6 @@ _DEPRECATED_DhcpServiceInfo = DeprecatedConstant( ) -@dataclass(slots=True) -class DhcpMatchers: - """Prepared info from dhcp entries.""" - - registered_devices_domains: set[str] - no_oui_matchers: dict[str, list[DHCPMatcher]] - oui_matchers: dict[str, list[DHCPMatcher]] - - def async_index_integration_matchers( integration_matchers: list[DHCPMatcher], ) -> DhcpMatchers: @@ -133,36 +122,34 @@ def async_index_integration_matchers( async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the dhcp component.""" - watchers: list[WatcherBase] = [] - address_data: dict[str, dict[str, str]] = {} integration_matchers = async_index_integration_matchers(await async_get_dhcp(hass)) + dhcp_data = DHCPData(integration_matchers=integration_matchers) + hass.data[DATA_DHCP] = dhcp_data + websocket_api.async_setup(hass) + watchers: list[WatcherBase] = [] # For the passive classes we need to start listening # for state changes and connect the dispatchers before # everything else starts up or we will miss events - device_watcher = DeviceTrackerWatcher(hass, address_data, integration_matchers) + device_watcher = DeviceTrackerWatcher(hass, dhcp_data) device_watcher.async_start() watchers.append(device_watcher) - device_tracker_registered_watcher = DeviceTrackerRegisteredWatcher( - hass, address_data, integration_matchers - ) + device_tracker_registered_watcher = DeviceTrackerRegisteredWatcher(hass, dhcp_data) device_tracker_registered_watcher.async_start() watchers.append(device_tracker_registered_watcher) async def _async_initialize(event: Event) -> None: await aiodhcpwatcher.async_init() - network_watcher = NetworkWatcher(hass, address_data, integration_matchers) + network_watcher = NetworkWatcher(hass, dhcp_data) network_watcher.async_start() watchers.append(network_watcher) - dhcp_watcher = DHCPWatcher(hass, address_data, integration_matchers) + dhcp_watcher = DHCPWatcher(hass, dhcp_data) await dhcp_watcher.async_start() watchers.append(dhcp_watcher) - rediscovery_watcher = RediscoveryWatcher( - hass, address_data, integration_matchers - ) + rediscovery_watcher = RediscoveryWatcher(hass, dhcp_data) rediscovery_watcher.async_start() watchers.append(rediscovery_watcher) @@ -180,18 +167,13 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: class WatcherBase: """Base class for dhcp and device tracker watching.""" - def __init__( - self, - hass: HomeAssistant, - address_data: dict[str, dict[str, str]], - integration_matchers: DhcpMatchers, - ) -> None: + def __init__(self, hass: HomeAssistant, dhcp_data: DHCPData) -> None: """Initialize class.""" super().__init__() - self.hass = hass - self._integration_matchers = integration_matchers - self._address_data = address_data + self._callbacks = dhcp_data.callbacks + self._integration_matchers = dhcp_data.integration_matchers + self._address_data = dhcp_data.address_data self._unsub: Callable[[], None] | None = None @callback @@ -230,18 +212,18 @@ class WatcherBase: mac_address = formatted_mac.replace(":", "") compressed_ip_address = made_ip_address.compressed - data = self._address_data.get(mac_address) + current_data = self._address_data.get(mac_address) if ( not force - and data - and data[IP_ADDRESS] == compressed_ip_address - and data[HOSTNAME].startswith(hostname) + and current_data + and current_data[IP_ADDRESS] == compressed_ip_address + and current_data[HOSTNAME].startswith(hostname) ): # If the address data is the same no need # to process it return - data = {IP_ADDRESS: compressed_ip_address, HOSTNAME: hostname} + data: DHCPAddressData = {IP_ADDRESS: compressed_ip_address, HOSTNAME: hostname} self._address_data[mac_address] = data lowercase_hostname = hostname.lower() @@ -287,9 +269,19 @@ class WatcherBase: _LOGGER.debug("Matched %s against %s", data, matcher) matched_domains.add(domain) - if not matched_domains: - return # avoid creating DiscoveryKey if there are no matches + if self._callbacks: + address_data = {mac_address: data} + for callback_ in self._callbacks: + callback_(address_data) + service_info: _DhcpServiceInfo | None = None + if not matched_domains: + return + service_info = _DhcpServiceInfo( + ip=ip_address, + hostname=lowercase_hostname, + macaddress=mac_address, + ) discovery_key = DiscoveryKey( domain=DOMAIN, key=mac_address, @@ -300,11 +292,7 @@ class WatcherBase: self.hass, domain, {"source": config_entries.SOURCE_DHCP}, - _DhcpServiceInfo( - ip=ip_address, - hostname=lowercase_hostname, - macaddress=mac_address, - ), + service_info, discovery_key=discovery_key, ) @@ -315,11 +303,10 @@ class NetworkWatcher(WatcherBase): def __init__( self, hass: HomeAssistant, - address_data: dict[str, dict[str, str]], - integration_matchers: DhcpMatchers, + dhcp_data: DHCPData, ) -> None: """Initialize class.""" - super().__init__(hass, address_data, integration_matchers) + super().__init__(hass, dhcp_data) self._discover_hosts: DiscoverHosts | None = None self._discover_task: asyncio.Task | None = None diff --git a/homeassistant/components/dhcp/const.py b/homeassistant/components/dhcp/const.py index c28a699c64c..c3bf8c512db 100644 --- a/homeassistant/components/dhcp/const.py +++ b/homeassistant/components/dhcp/const.py @@ -1,3 +1,8 @@ """Constants for the dhcp integration.""" +from typing import Final + DOMAIN = "dhcp" +HOSTNAME: Final = "hostname" +MAC_ADDRESS: Final = "macaddress" +IP_ADDRESS: Final = "ip" diff --git a/homeassistant/components/dhcp/helpers.py b/homeassistant/components/dhcp/helpers.py new file mode 100644 index 00000000000..e5ab767ee71 --- /dev/null +++ b/homeassistant/components/dhcp/helpers.py @@ -0,0 +1,37 @@ +"""The dhcp integration.""" + +from __future__ import annotations + +from collections.abc import Callable +from functools import partial + +from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback + +from .models import DATA_DHCP, DHCPAddressData + + +@callback +def async_register_dhcp_callback_internal( + hass: HomeAssistant, + callback_: Callable[[dict[str, DHCPAddressData]], None], +) -> CALLBACK_TYPE: + """Register a dhcp callback. + + For internal use only. + This is not intended for use by integrations. + """ + callbacks = hass.data[DATA_DHCP].callbacks + callbacks.add(callback_) + return partial(callbacks.remove, callback_) + + +@callback +def async_get_address_data_internal( + hass: HomeAssistant, +) -> dict[str, DHCPAddressData]: + """Get the address data. + + For internal use only. + This is not intended for use by integrations. + """ + return hass.data[DATA_DHCP].address_data diff --git a/homeassistant/components/dhcp/models.py b/homeassistant/components/dhcp/models.py new file mode 100644 index 00000000000..d26993e7f0f --- /dev/null +++ b/homeassistant/components/dhcp/models.py @@ -0,0 +1,43 @@ +"""The dhcp integration.""" + +from __future__ import annotations + +from collections.abc import Callable +import dataclasses +from dataclasses import dataclass +from typing import TypedDict + +from homeassistant.loader import DHCPMatcher +from homeassistant.util.hass_dict import HassKey + +from .const import DOMAIN + + +@dataclass(slots=True) +class DhcpMatchers: + """Prepared info from dhcp entries.""" + + registered_devices_domains: set[str] + no_oui_matchers: dict[str, list[DHCPMatcher]] + oui_matchers: dict[str, list[DHCPMatcher]] + + +class DHCPAddressData(TypedDict): + """Typed dict for DHCP address data.""" + + hostname: str + ip: str + + +@dataclasses.dataclass(slots=True) +class DHCPData: + """Data for the dhcp component.""" + + integration_matchers: DhcpMatchers + callbacks: set[Callable[[dict[str, DHCPAddressData]], None]] = dataclasses.field( + default_factory=set + ) + address_data: dict[str, DHCPAddressData] = dataclasses.field(default_factory=dict) + + +DATA_DHCP: HassKey[DHCPData] = HassKey(DOMAIN) diff --git a/homeassistant/components/dhcp/websocket_api.py b/homeassistant/components/dhcp/websocket_api.py new file mode 100644 index 00000000000..e6682de2158 --- /dev/null +++ b/homeassistant/components/dhcp/websocket_api.py @@ -0,0 +1,63 @@ +"""The dhcp integration websocket apis.""" + +from __future__ import annotations + +from typing import Any + +import voluptuous as vol + +from homeassistant.components import websocket_api +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import device_registry as dr +from homeassistant.helpers.json import json_bytes + +from .const import HOSTNAME, IP_ADDRESS +from .helpers import ( + async_get_address_data_internal, + async_register_dhcp_callback_internal, +) +from .models import DHCPAddressData + + +@callback +def async_setup(hass: HomeAssistant) -> None: + """Set up the DHCP websocket API.""" + websocket_api.async_register_command(hass, ws_subscribe_discovery) + + +@websocket_api.require_admin +@websocket_api.websocket_command( + { + vol.Required("type"): "dhcp/subscribe_discovery", + } +) +@websocket_api.async_response +async def ws_subscribe_discovery( + hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any] +) -> None: + """Handle subscribe discovery websocket command.""" + ws_msg_id: int = msg["id"] + + def _async_send(address_data: dict[str, DHCPAddressData]) -> None: + connection.send_message( + json_bytes( + websocket_api.event_message( + ws_msg_id, + { + "add": [ + { + "mac_address": dr.format_mac(mac_address).upper(), + "hostname": data[HOSTNAME], + "ip_address": data[IP_ADDRESS], + } + for mac_address, data in address_data.items() + ] + }, + ) + ) + ) + + unsub = async_register_dhcp_callback_internal(hass, _async_send) + connection.subscriptions[ws_msg_id] = unsub + connection.send_message(json_bytes(websocket_api.result_message(ws_msg_id))) + _async_send(async_get_address_data_internal(hass)) diff --git a/tests/components/dhcp/test_init.py b/tests/components/dhcp/test_init.py index 223dc83f83a..f036902faed 100644 --- a/tests/components/dhcp/test_init.py +++ b/tests/components/dhcp/test_init.py @@ -1,5 +1,7 @@ """Test the DHCP discovery integration.""" +from __future__ import annotations + from collections.abc import Awaitable, Callable import datetime import threading @@ -24,6 +26,7 @@ from homeassistant.components.device_tracker import ( SourceType, ) from homeassistant.components.dhcp.const import DOMAIN +from homeassistant.components.dhcp.models import DHCPData from homeassistant.const import ( EVENT_HOMEASSISTANT_STARTED, EVENT_HOMEASSISTANT_STOP, @@ -147,12 +150,12 @@ async def _async_get_handle_dhcp_packet( integration_matchers: dhcp.DhcpMatchers, address_data: dict | None = None, ) -> Callable[[Any], Awaitable[None]]: + """Make a handler for a dhcp packet.""" if address_data is None: address_data = {} dhcp_watcher = dhcp.DHCPWatcher( hass, - address_data, - integration_matchers, + DHCPData(integration_matchers, set(), address_data), ) with patch("aiodhcpwatcher.async_start"): await dhcp_watcher.async_start() @@ -666,6 +669,45 @@ async def test_setup_fails_with_broken_libpcap( ) +def _make_device_tracker_watcher( + hass: HomeAssistant, matchers: list[dhcp.DHCPMatcher] +) -> dhcp.DeviceTrackerWatcher: + return dhcp.DeviceTrackerWatcher( + hass, + DHCPData( + dhcp.async_index_integration_matchers(matchers), + set(), + {}, + ), + ) + + +def _make_device_tracker_registered_watcher( + hass: HomeAssistant, matchers: list[dhcp.DHCPMatcher] +) -> dhcp.DeviceTrackerRegisteredWatcher: + return dhcp.DeviceTrackerRegisteredWatcher( + hass, + DHCPData( + dhcp.async_index_integration_matchers(matchers), + set(), + {}, + ), + ) + + +def _make_network_watcher( + hass: HomeAssistant, matchers: list[dhcp.DHCPMatcher] +) -> dhcp.NetworkWatcher: + return dhcp.NetworkWatcher( + hass, + DHCPData( + dhcp.async_index_integration_matchers(matchers), + set(), + {}, + ), + ) + + async def test_device_tracker_hostname_and_macaddress_exists_before_start( hass: HomeAssistant, ) -> None: @@ -682,18 +724,15 @@ async def test_device_tracker_hostname_and_macaddress_exists_before_start( ) with patch.object(hass.config_entries.flow, "async_init") as mock_init: - device_tracker_watcher = dhcp.DeviceTrackerWatcher( + device_tracker_watcher = _make_device_tracker_watcher( hass, - {}, - dhcp.async_index_integration_matchers( - [ - { - "domain": "mock-domain", - "hostname": "connect", - "macaddress": "B8B7F1*", - } - ] - ), + [ + { + "domain": "mock-domain", + "hostname": "connect", + "macaddress": "B8B7F1*", + } + ], ) device_tracker_watcher.async_start() await hass.async_block_till_done() @@ -716,18 +755,15 @@ async def test_device_tracker_hostname_and_macaddress_exists_before_start( async def test_device_tracker_registered(hass: HomeAssistant) -> None: """Test matching based on hostname and macaddress when registered.""" with patch.object(hass.config_entries.flow, "async_init") as mock_init: - device_tracker_watcher = dhcp.DeviceTrackerRegisteredWatcher( + device_tracker_watcher = _make_device_tracker_registered_watcher( hass, - {}, - dhcp.async_index_integration_matchers( - [ - { - "domain": "mock-domain", - "hostname": "connect", - "macaddress": "B8B7F1*", - } - ] - ), + [ + { + "domain": "mock-domain", + "hostname": "connect", + "macaddress": "B8B7F1*", + } + ], ) device_tracker_watcher.async_start() await hass.async_block_till_done() @@ -756,18 +792,15 @@ async def test_device_tracker_registered(hass: HomeAssistant) -> None: async def test_device_tracker_registered_hostname_none(hass: HomeAssistant) -> None: """Test handle None hostname.""" with patch.object(hass.config_entries.flow, "async_init") as mock_init: - device_tracker_watcher = dhcp.DeviceTrackerRegisteredWatcher( + device_tracker_watcher = _make_device_tracker_watcher( hass, - {}, - dhcp.async_index_integration_matchers( - [ - { - "domain": "mock-domain", - "hostname": "connect", - "macaddress": "B8B7F1*", - } - ] - ), + [ + { + "domain": "mock-domain", + "hostname": "connect", + "macaddress": "B8B7F1*", + } + ], ) device_tracker_watcher.async_start() await hass.async_block_till_done() @@ -789,18 +822,15 @@ async def test_device_tracker_hostname_and_macaddress_after_start( """Test matching based on hostname and macaddress after start.""" with patch.object(hass.config_entries.flow, "async_init") as mock_init: - device_tracker_watcher = dhcp.DeviceTrackerWatcher( + device_tracker_watcher = _make_device_tracker_watcher( hass, - {}, - dhcp.async_index_integration_matchers( - [ - { - "domain": "mock-domain", - "hostname": "connect", - "macaddress": "B8B7F1*", - } - ] - ), + [ + { + "domain": "mock-domain", + "hostname": "connect", + "macaddress": "B8B7F1*", + } + ], ) device_tracker_watcher.async_start() await hass.async_block_till_done() @@ -837,18 +867,15 @@ async def test_device_tracker_hostname_and_macaddress_after_start_not_home( """Test matching based on hostname and macaddress after start but not home.""" with patch.object(hass.config_entries.flow, "async_init") as mock_init: - device_tracker_watcher = dhcp.DeviceTrackerWatcher( + device_tracker_watcher = _make_device_tracker_watcher( hass, - {}, - dhcp.async_index_integration_matchers( - [ - { - "domain": "mock-domain", - "hostname": "connect", - "macaddress": "B8B7F1*", - } - ] - ), + [ + { + "domain": "mock-domain", + "hostname": "connect", + "macaddress": "B8B7F1*", + } + ], ) device_tracker_watcher.async_start() await hass.async_block_till_done() @@ -875,9 +902,8 @@ async def test_device_tracker_hostname_and_macaddress_after_start_not_router( """Test matching based on hostname and macaddress after start but not router.""" with patch.object(hass.config_entries.flow, "async_init") as mock_init: - device_tracker_watcher = dhcp.DeviceTrackerWatcher( + device_tracker_watcher = _make_device_tracker_watcher( hass, - {}, [{"domain": "mock-domain", "hostname": "connect", "macaddress": "B8B7F1*"}], ) device_tracker_watcher.async_start() @@ -905,9 +931,8 @@ async def test_device_tracker_hostname_and_macaddress_after_start_hostname_missi """Test matching based on hostname and macaddress after start but missing hostname.""" with patch.object(hass.config_entries.flow, "async_init") as mock_init: - device_tracker_watcher = dhcp.DeviceTrackerWatcher( + device_tracker_watcher = _make_device_tracker_watcher( hass, - {}, [{"domain": "mock-domain", "hostname": "connect", "macaddress": "B8B7F1*"}], ) device_tracker_watcher.async_start() @@ -934,9 +959,8 @@ async def test_device_tracker_invalid_ip_address( """Test an invalid ip address.""" with patch.object(hass.config_entries.flow, "async_init") as mock_init: - device_tracker_watcher = dhcp.DeviceTrackerWatcher( + device_tracker_watcher = _make_device_tracker_watcher( hass, - {}, [{"domain": "mock-domain", "hostname": "connect", "macaddress": "B8B7F1*"}], ) device_tracker_watcher.async_start() @@ -974,18 +998,15 @@ async def test_device_tracker_ignore_self_assigned_ips_before_start( ) with patch.object(hass.config_entries.flow, "async_init") as mock_init: - device_tracker_watcher = dhcp.DeviceTrackerWatcher( + device_tracker_watcher = _make_device_tracker_watcher( hass, - {}, - dhcp.async_index_integration_matchers( - [ - { - "domain": "mock-domain", - "hostname": "connect", - "macaddress": "B8B7F1*", - } - ] - ), + [ + { + "domain": "mock-domain", + "hostname": "connect", + "macaddress": "B8B7F1*", + } + ], ) device_tracker_watcher.async_start() await hass.async_block_till_done() @@ -1010,18 +1031,15 @@ async def test_aiodiscover_finds_new_hosts(hass: HomeAssistant) -> None: ], ), ): - device_tracker_watcher = dhcp.NetworkWatcher( + device_tracker_watcher = _make_network_watcher( hass, - {}, - dhcp.async_index_integration_matchers( - [ - { - "domain": "mock-domain", - "hostname": "connect", - "macaddress": "B8B7F1*", - } - ] - ), + [ + { + "domain": "mock-domain", + "hostname": "connect", + "macaddress": "B8B7F1*", + } + ], ) device_tracker_watcher.async_start() await hass.async_block_till_done() @@ -1073,18 +1091,15 @@ async def test_aiodiscover_does_not_call_again_on_shorter_hostname( ], ), ): - device_tracker_watcher = dhcp.NetworkWatcher( + device_tracker_watcher = _make_network_watcher( hass, - {}, - dhcp.async_index_integration_matchers( - [ - { - "domain": "mock-domain", - "hostname": "irobot-*", - "macaddress": "B8B7F1*", - } - ] - ), + [ + { + "domain": "mock-domain", + "hostname": "irobot-*", + "macaddress": "B8B7F1*", + } + ], ) device_tracker_watcher.async_start() await hass.async_block_till_done() @@ -1123,19 +1138,17 @@ async def test_aiodiscover_finds_new_hosts_after_interval(hass: HomeAssistant) - return_value=[], ), ): - device_tracker_watcher = dhcp.NetworkWatcher( + device_tracker_watcher = _make_network_watcher( hass, - {}, - dhcp.async_index_integration_matchers( - [ - { - "domain": "mock-domain", - "hostname": "connect", - "macaddress": "B8B7F1*", - } - ] - ), + [ + { + "domain": "mock-domain", + "hostname": "connect", + "macaddress": "B8B7F1*", + } + ], ) + device_tracker_watcher.async_start() await hass.async_block_till_done() @@ -1235,7 +1248,7 @@ async def test_dhcp_rediscover( hass, integration_matchers, address_data ) rediscovery_watcher = dhcp.RediscoveryWatcher( - hass, address_data, integration_matchers + hass, DHCPData(integration_matchers, set(), address_data) ) rediscovery_watcher.async_start() with patch.object(hass.config_entries.flow, "async_init") as mock_init: @@ -1329,7 +1342,7 @@ async def test_dhcp_rediscover_no_match( hass, integration_matchers, address_data ) rediscovery_watcher = dhcp.RediscoveryWatcher( - hass, address_data, integration_matchers + hass, DHCPData(integration_matchers, set(), address_data) ) rediscovery_watcher.async_start() with patch.object(hass.config_entries.flow, "async_init") as mock_init: diff --git a/tests/components/dhcp/test_websocket_api.py b/tests/components/dhcp/test_websocket_api.py new file mode 100644 index 00000000000..eb008c49ab1 --- /dev/null +++ b/tests/components/dhcp/test_websocket_api.py @@ -0,0 +1,75 @@ +"""The tests for the dhcp WebSocket API.""" + +import asyncio +from collections.abc import Callable +from unittest.mock import patch + +import aiodhcpwatcher + +from homeassistant.components.dhcp import DOMAIN +from homeassistant.core import EVENT_HOMEASSISTANT_STARTED, HomeAssistant +from homeassistant.setup import async_setup_component + +from tests.typing import WebSocketGenerator + + +async def test_subscribe_discovery( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test dhcp subscribe_discovery.""" + saved_callback: Callable[[aiodhcpwatcher.DHCPRequest], None] | None = None + + async def mock_start( + callback: Callable[[aiodhcpwatcher.DHCPRequest], None], + ) -> None: + """Mock start.""" + nonlocal saved_callback + saved_callback = callback + + with ( + patch("homeassistant.components.dhcp.aiodhcpwatcher.async_start", mock_start), + patch("homeassistant.components.dhcp.DiscoverHosts"), + ): + await async_setup_component(hass, DOMAIN, {}) + await hass.async_block_till_done() + hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) + await hass.async_block_till_done() + + saved_callback(aiodhcpwatcher.DHCPRequest("4.3.2.2", "happy", "44:44:33:11:23:12")) + client = await hass_ws_client() + await client.send_json( + { + "id": 1, + "type": "dhcp/subscribe_discovery", + } + ) + async with asyncio.timeout(1): + response = await client.receive_json() + assert response["success"] + + async with asyncio.timeout(1): + response = await client.receive_json() + assert response["event"] == { + "add": [ + { + "hostname": "happy", + "ip_address": "4.3.2.2", + "mac_address": "44:44:33:11:23:12", + } + ] + } + + saved_callback(aiodhcpwatcher.DHCPRequest("4.3.2.1", "sad", "44:44:33:11:23:13")) + + async with asyncio.timeout(1): + response = await client.receive_json() + assert response["event"] == { + "add": [ + { + "hostname": "sad", + "ip_address": "4.3.2.1", + "mac_address": "44:44:33:11:23:13", + } + ] + } From 849121a1247474d43cf36bd74e5b973cc1706ed6 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 21 Apr 2025 04:25:14 -1000 Subject: [PATCH 66/70] Improve human-readable name for new/reauth/reconfig in ESPHome (#143302) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Improve human-readable prompt when requesting ESPHome credentials Users reported difficulty identifying which device needs reauthentication, especially when names are similar (e.g., `power-meter` vs `power-meter-EEFF`). Previously, only the hostname was shown, which led to confusion. This change includes the config entry title or friendly name—when available—in the prompt to make device identification easier. * Update homeassistant/components/esphome/config_flow.py * add missing cover * tweaks * one more * one more * cover * some are ``, some are not, make them all `` * Apply suggestions from code review --------- Co-authored-by: Paulus Schoutsen --- .../components/esphome/config_flow.py | 47 ++++++-- homeassistant/components/esphome/strings.json | 8 +- tests/components/esphome/test_config_flow.py | 107 +++++++++++++++++- 3 files changed, 146 insertions(+), 16 deletions(-) diff --git a/homeassistant/components/esphome/config_flow.py b/homeassistant/components/esphome/config_flow.py index 72d670ee029..03dab1f408c 100644 --- a/homeassistant/components/esphome/config_flow.py +++ b/homeassistant/components/esphome/config_flow.py @@ -57,6 +57,7 @@ ERROR_INVALID_ENCRYPTION_KEY = "invalid_psk" _LOGGER = logging.getLogger(__name__) ZERO_NOISE_PSK = "MDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDA=" +DEFAULT_NAME = "ESPHome" class EsphomeFlowHandler(ConfigFlow, domain=DOMAIN): @@ -117,8 +118,8 @@ class EsphomeFlowHandler(ConfigFlow, domain=DOMAIN): self._host = entry_data[CONF_HOST] self._port = entry_data[CONF_PORT] self._password = entry_data[CONF_PASSWORD] - self._name = self._reauth_entry.title self._device_name = entry_data.get(CONF_DEVICE_NAME) + self._name = self._reauth_entry.title # Device without encryption allows fetching device info. We can then check # if the device is no longer using a password. If we did try with a password, @@ -147,7 +148,7 @@ class EsphomeFlowHandler(ConfigFlow, domain=DOMAIN): return self.async_show_form( step_id="reauth_encryption_removed_confirm", - description_placeholders={"name": self._name}, + description_placeholders={"name": self._async_get_human_readable_name()}, ) async def async_step_reauth_confirm( @@ -172,7 +173,7 @@ class EsphomeFlowHandler(ConfigFlow, domain=DOMAIN): step_id="reauth_confirm", data_schema=vol.Schema({vol.Required(CONF_NOISE_PSK): str}), errors=errors, - description_placeholders={"name": self._name}, + description_placeholders={"name": self._async_get_human_readable_name()}, ) async def async_step_reconfigure( @@ -189,12 +190,14 @@ class EsphomeFlowHandler(ConfigFlow, domain=DOMAIN): @property def _name(self) -> str: - return self.__name or "ESPHome" + return self.__name or DEFAULT_NAME @_name.setter def _name(self, value: str) -> None: self.__name = value - self.context["title_placeholders"] = {"name": self._name} + self.context["title_placeholders"] = { + "name": self._async_get_human_readable_name() + } async def _async_try_fetch_device_info(self) -> ConfigFlowResult: """Try to fetch device info and return any errors.""" @@ -254,7 +257,8 @@ class EsphomeFlowHandler(ConfigFlow, domain=DOMAIN): if user_input is not None: return await self._async_try_fetch_device_info() return self.async_show_form( - step_id="discovery_confirm", description_placeholders={"name": self._name} + step_id="discovery_confirm", + description_placeholders={"name": self._async_get_human_readable_name()}, ) async def async_step_zeroconf( @@ -274,8 +278,8 @@ class EsphomeFlowHandler(ConfigFlow, domain=DOMAIN): # Hostname is format: livingroom.local. device_name = discovery_info.hostname.removesuffix(".local.") - self._name = discovery_info.properties.get("friendly_name", device_name) self._device_name = device_name + self._name = discovery_info.properties.get("friendly_name", device_name) self._host = discovery_info.host self._port = discovery_info.port self._noise_required = bool(discovery_info.properties.get("api_encryption")) @@ -593,9 +597,30 @@ class EsphomeFlowHandler(ConfigFlow, domain=DOMAIN): step_id="encryption_key", data_schema=vol.Schema({vol.Required(CONF_NOISE_PSK): str}), errors=errors, - description_placeholders={"name": self._name}, + description_placeholders={"name": self._async_get_human_readable_name()}, ) + @callback + def _async_get_human_readable_name(self) -> str: + """Return a human readable name for the entry.""" + entry: ConfigEntry | None = None + if self.source == SOURCE_REAUTH: + entry = self._reauth_entry + elif self.source == SOURCE_RECONFIGURE: + entry = self._reconfig_entry + friendly_name = self._name + device_name = self._device_name + if ( + device_name + and friendly_name in (DEFAULT_NAME, device_name) + and entry + and entry.title != friendly_name + ): + friendly_name = entry.title + if not device_name or friendly_name == device_name: + return friendly_name + return f"{friendly_name} ({device_name})" + async def async_step_authenticate( self, user_input: dict[str, Any] | None = None, error: str | None = None ) -> ConfigFlowResult: @@ -614,7 +639,7 @@ class EsphomeFlowHandler(ConfigFlow, domain=DOMAIN): return self.async_show_form( step_id="authenticate", data_schema=vol.Schema({vol.Required("password"): str}), - description_placeholders={"name": self._name}, + description_placeholders={"name": self._async_get_human_readable_name()}, errors=errors, ) @@ -648,9 +673,9 @@ class EsphomeFlowHandler(ConfigFlow, domain=DOMAIN): return "connection_error" finally: await cli.disconnect(force=True) - self._name = self._device_info.friendly_name or self._device_info.name - self._device_name = self._device_info.name self._device_mac = format_mac(self._device_info.mac_address) + self._device_name = self._device_info.name + self._name = self._device_info.friendly_name or self._device_info.name return None async def fetch_device_info(self) -> str | None: diff --git a/homeassistant/components/esphome/strings.json b/homeassistant/components/esphome/strings.json index b95537e448e..68d641def6c 100644 --- a/homeassistant/components/esphome/strings.json +++ b/homeassistant/components/esphome/strings.json @@ -43,7 +43,7 @@ "data_description": { "password": "Passwords are deprecated and will be removed in a future version. Please update your ESPHome device YAML configuration to use an encryption key instead." }, - "description": "Please enter the password you set in your ESPHome device YAML configuration for {name}." + "description": "Please enter the password you set in your ESPHome device YAML configuration for `{name}`." }, "encryption_key": { "data": { @@ -52,7 +52,7 @@ "data_description": { "noise_psk": "The encryption key is used to encrypt the connection between Home Assistant and the ESPHome device. You can find this in the api: section of your ESPHome device YAML configuration." }, - "description": "Please enter the encryption key for {name}. You can find it in the ESPHome Dashboard or in your ESPHome device YAML configuration." + "description": "Please enter the encryption key for `{name}`. You can find it in the ESPHome Dashboard or in your ESPHome device YAML configuration." }, "reauth_confirm": { "data": { @@ -61,10 +61,10 @@ "data_description": { "noise_psk": "[%key:component::esphome::config::step::encryption_key::data_description::noise_psk%]" }, - "description": "The ESPHome device {name} enabled transport encryption or changed the encryption key. Please enter the updated key. You can find it in the ESPHome Dashboard or in your ESPHome device YAML configuration." + "description": "The ESPHome device `{name}` enabled transport encryption or changed the encryption key. Please enter the updated key. You can find it in the ESPHome Dashboard or in your ESPHome device YAML configuration." }, "reauth_encryption_removed_confirm": { - "description": "The ESPHome device {name} disabled transport encryption. Please confirm that you want to remove the encryption key and allow unencrypted connections." + "description": "The ESPHome device `{name}` disabled transport encryption. Please confirm that you want to remove the encryption key and allow unencrypted connections." }, "discovery_confirm": { "description": "Do you want to add the device `{name}` to Home Assistant?", diff --git a/tests/components/esphome/test_config_flow.py b/tests/components/esphome/test_config_flow.py index 19a42792ec2..3e81df734b3 100644 --- a/tests/components/esphome/test_config_flow.py +++ b/tests/components/esphome/test_config_flow.py @@ -27,6 +27,7 @@ from homeassistant.components.esphome.const import ( DEFAULT_NEW_CONFIG_ALLOW_ALLOW_SERVICE_CALLS, DOMAIN, ) +from homeassistant.config_entries import ConfigFlowResult from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_PORT from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType @@ -50,6 +51,17 @@ def mock_setup_entry(): yield +def get_flow_context(hass: HomeAssistant, result: ConfigFlowResult) -> dict[str, Any]: + """Get the flow context from the result of async_init or async_configure.""" + flow = next( + flow + for flow in hass.config_entries.flow.async_progress() + if flow["flow_id"] == result["flow_id"] + ) + + return flow["context"] + + @pytest.mark.usefixtures("mock_zeroconf") async def test_user_connection_works( hass: HomeAssistant, mock_client, mock_setup_entry: None @@ -150,6 +162,9 @@ async def test_user_sets_unique_id( assert discovery_result["type"] is FlowResultType.FORM assert discovery_result["step_id"] == "discovery_confirm" + assert discovery_result["description_placeholders"] == { + "name": "test8266", + } discovery_result = await hass.config_entries.flow.async_configure( discovery_result["flow_id"], @@ -234,6 +249,9 @@ async def test_user_causes_zeroconf_to_abort( assert discovery_result["type"] is FlowResultType.FORM assert discovery_result["step_id"] == "discovery_confirm" + assert discovery_result["description_placeholders"] == { + "name": "test8266", + } result = await hass.config_entries.flow.async_init( "esphome", @@ -297,6 +315,7 @@ async def test_user_with_password( assert result["type"] is FlowResultType.FORM assert result["step_id"] == "authenticate" + assert result["description_placeholders"] == {"name": "test"} result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input={CONF_PASSWORD: "password1"} @@ -326,6 +345,7 @@ async def test_user_invalid_password(hass: HomeAssistant, mock_client) -> None: assert result["type"] is FlowResultType.FORM assert result["step_id"] == "authenticate" + assert result["description_placeholders"] == {"name": "test"} mock_client.connect.side_effect = InvalidAuthAPIError @@ -335,6 +355,7 @@ async def test_user_invalid_password(hass: HomeAssistant, mock_client) -> None: assert result["type"] is FlowResultType.FORM assert result["step_id"] == "authenticate" + assert result["description_placeholders"] == {"name": "test"} assert result["errors"] == {"base": "invalid_auth"} @@ -348,7 +369,7 @@ async def test_user_dashboard_has_wrong_key( """Test user step with key from dashboard that is incorrect.""" mock_client.device_info.side_effect = [ RequiresEncryptionAPIError, - InvalidEncryptionKeyAPIError, + InvalidEncryptionKeyAPIError("Wrong key", "test"), DeviceInfo( uses_password=False, name="test", @@ -369,6 +390,7 @@ async def test_user_dashboard_has_wrong_key( assert result["type"] is FlowResultType.FORM assert result["step_id"] == "encryption_key" + assert result["description_placeholders"] == {"name": "test"} result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input={CONF_NOISE_PSK: VALID_NOISE_PSK} @@ -477,6 +499,7 @@ async def test_user_discovers_name_and_gets_key_from_dashboard_fails( assert result["type"] is FlowResultType.FORM assert result["step_id"] == "encryption_key" + assert result["description_placeholders"] == {"name": "test"} result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input={CONF_NOISE_PSK: VALID_NOISE_PSK} @@ -532,6 +555,7 @@ async def test_user_discovers_name_and_dashboard_is_unavailable( assert result["type"] is FlowResultType.FORM assert result["step_id"] == "encryption_key" + assert result["description_placeholders"] == {"name": "test"} result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input={CONF_NOISE_PSK: VALID_NOISE_PSK} @@ -563,6 +587,7 @@ async def test_login_connection_error( assert result["type"] is FlowResultType.FORM assert result["step_id"] == "authenticate" + assert result["description_placeholders"] == {"name": "test"} mock_client.connect.side_effect = APIConnectionError @@ -572,6 +597,7 @@ async def test_login_connection_error( assert result["type"] is FlowResultType.FORM assert result["step_id"] == "authenticate" + assert result["description_placeholders"] == {"name": "test"} assert result["errors"] == {"base": "connection_error"} @@ -588,12 +614,18 @@ async def test_discovery_initiation( port=6053, properties={ "mac": "1122334455aa", + "friendly_name": "The Test", }, type="mock_type", ) flow = await hass.config_entries.flow.async_init( "esphome", context={"source": config_entries.SOURCE_ZEROCONF}, data=service_info ) + assert get_flow_context(hass, flow) == { + "source": config_entries.SOURCE_ZEROCONF, + "title_placeholders": {"name": "The Test (test)"}, + "unique_id": "11:22:33:44:55:aa", + } result = await hass.config_entries.flow.async_configure( flow["flow_id"], user_input={} @@ -682,6 +714,7 @@ async def test_discovery_duplicate_data( ) assert result["type"] is FlowResultType.FORM assert result["step_id"] == "discovery_confirm" + assert result["description_placeholders"] == {"name": "test"} result = await hass.config_entries.flow.async_init( "esphome", data=service_info, context={"source": config_entries.SOURCE_ZEROCONF} @@ -742,6 +775,7 @@ async def test_user_requires_psk( assert result["type"] is FlowResultType.FORM assert result["step_id"] == "encryption_key" assert result["errors"] == {} + assert result["description_placeholders"] == {"name": "ESPHome"} assert len(mock_client.connect.mock_calls) == 2 assert len(mock_client.device_info.mock_calls) == 2 @@ -764,6 +798,7 @@ async def test_encryption_key_valid_psk( assert result["type"] is FlowResultType.FORM assert result["step_id"] == "encryption_key" + assert result["description_placeholders"] == {"name": "ESPHome"} mock_client.device_info = AsyncMock( return_value=DeviceInfo(uses_password=False, name="test") @@ -799,6 +834,7 @@ async def test_encryption_key_invalid_psk( assert result["type"] is FlowResultType.FORM assert result["step_id"] == "encryption_key" + assert result["description_placeholders"] == {"name": "ESPHome"} mock_client.device_info.side_effect = InvalidEncryptionKeyAPIError result = await hass.config_entries.flow.async_configure( @@ -808,6 +844,7 @@ async def test_encryption_key_invalid_psk( assert result["type"] is FlowResultType.FORM assert result["step_id"] == "encryption_key" assert result["errors"] == {"base": "invalid_psk"} + assert result["description_placeholders"] == {"name": "ESPHome"} assert mock_client.noise_psk == INVALID_NOISE_PSK @@ -823,6 +860,9 @@ async def test_reauth_initiation(hass: HomeAssistant, mock_client) -> None: result = await entry.start_reauth_flow(hass) assert result["type"] is FlowResultType.FORM assert result["step_id"] == "reauth_confirm" + assert result["description_placeholders"] == { + "name": "Mock Title (test)", + } @pytest.mark.usefixtures("mock_zeroconf") @@ -1025,6 +1065,9 @@ async def test_reauth_fixed_via_dashboard_at_confirm( assert result["type"] is FlowResultType.FORM, result assert result["step_id"] == "reauth_confirm" + assert result["description_placeholders"] == { + "name": "Mock Title (test)", + } mock_dashboard["configured"].append( { @@ -1070,6 +1113,9 @@ async def test_reauth_confirm_invalid( assert result["type"] is FlowResultType.FORM assert result["step_id"] == "reauth_confirm" + assert result["description_placeholders"] == { + "name": "Mock Title (test)", + } assert result["errors"] assert result["errors"]["base"] == "invalid_psk" @@ -1108,6 +1154,9 @@ async def test_reauth_confirm_invalid_with_unique_id( assert result["type"] is FlowResultType.FORM assert result["step_id"] == "reauth_confirm" + assert result["description_placeholders"] == { + "name": "Mock Title (test)", + } assert result["errors"] assert result["errors"]["base"] == "invalid_psk" @@ -1145,6 +1194,9 @@ async def test_reauth_encryption_key_removed( result = await entry.start_reauth_flow(hass) assert result["type"] is FlowResultType.FORM assert result["step_id"] == "reauth_encryption_removed_confirm" + assert result["description_placeholders"] == { + "name": "Mock Title (test)", + } result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input={} @@ -1370,6 +1422,7 @@ async def test_zeroconf_encryption_key_via_dashboard( assert flow["type"] is FlowResultType.FORM assert flow["step_id"] == "discovery_confirm" + assert flow["description_placeholders"] == {"name": "test8266"} mock_dashboard["configured"].append( { @@ -1437,6 +1490,7 @@ async def test_zeroconf_encryption_key_via_dashboard_with_api_encryption_prop( assert flow["type"] is FlowResultType.FORM assert flow["step_id"] == "discovery_confirm" + assert flow["description_placeholders"] == {"name": "test8266"} mock_dashboard["configured"].append( { @@ -1502,6 +1556,7 @@ async def test_zeroconf_no_encryption_key_via_dashboard( assert flow["type"] is FlowResultType.FORM assert flow["step_id"] == "discovery_confirm" + assert flow["description_placeholders"] == {"name": "test8266"} await dashboard.async_get_dashboard(hass).async_refresh() @@ -1513,6 +1568,7 @@ async def test_zeroconf_no_encryption_key_via_dashboard( assert result["type"] is FlowResultType.FORM assert result["step_id"] == "encryption_key" + assert result["description_placeholders"] == {"name": "test8266"} async def test_option_flow_allow_service_calls( @@ -1625,6 +1681,7 @@ async def test_user_discovers_name_no_dashboard( assert result["type"] is FlowResultType.FORM assert result["step_id"] == "encryption_key" + assert result["description_placeholders"] == {"name": "test"} result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input={CONF_NOISE_PSK: VALID_NOISE_PSK} @@ -1917,6 +1974,54 @@ async def test_reconfig_success_with_new_ip_same_name( assert entry.data[CONF_NOISE_PSK] == VALID_NOISE_PSK +@pytest.mark.usefixtures("mock_zeroconf", "mock_setup_entry") +async def test_reconfig_success_noise_psk_changes( + hass: HomeAssistant, mock_client: APIClient +) -> None: + """Test reconfig initiation with new ip and new noise psk.""" + entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_HOST: "127.0.0.1", + CONF_PORT: 6053, + CONF_PASSWORD: "", + CONF_DEVICE_NAME: "test", + CONF_NOISE_PSK: VALID_NOISE_PSK, + }, + unique_id="11:22:33:44:55:aa", + ) + entry.add_to_hass(hass) + + result = await entry.start_reconfigure_flow(hass) + mock_client.device_info.side_effect = [ + RequiresEncryptionAPIError, + InvalidEncryptionKeyAPIError("Wrong key", "test"), + DeviceInfo(uses_password=False, name="test", mac_address="11:22:33:44:55:aa"), + ] + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={CONF_HOST: "127.0.0.1", CONF_PORT: 6053} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "encryption_key" + assert result["description_placeholders"] == {"name": "Mock Title (test)"} + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={CONF_NOISE_PSK: VALID_NOISE_PSK} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "encryption_key" + assert result["description_placeholders"] == {"name": "Mock Title (test)"} + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={CONF_NOISE_PSK: VALID_NOISE_PSK} + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" + assert entry.data[CONF_HOST] == "127.0.0.1" + assert entry.data[CONF_DEVICE_NAME] == "test" + assert entry.data[CONF_NOISE_PSK] == VALID_NOISE_PSK + + @pytest.mark.usefixtures("mock_zeroconf", "mock_setup_entry") async def test_reconfig_name_conflict_with_existing_entry( hass: HomeAssistant, mock_client: APIClient From 89a6bc4354e7ccae097dc9630001802ca8f1932d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ville=20Skytt=C3=A4?= Date: Mon, 21 Apr 2025 18:09:20 +0300 Subject: [PATCH 67/70] Huawei LTE icon improvements (#143342) * Set icon for Huawei LTE eNodeB ID This identifies the base station ~ tower, so use the tower icon for it. * Use antenna rather than (power) transmission tower across https://github.com/home-assistant/core/pull/143342#discussion_r2051781388 --- homeassistant/components/huawei_lte/icons.json | 2 +- homeassistant/components/huawei_lte/sensor.py | 5 +++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/huawei_lte/icons.json b/homeassistant/components/huawei_lte/icons.json index a338cc65ed4..22eb345eba5 100644 --- a/homeassistant/components/huawei_lte/icons.json +++ b/homeassistant/components/huawei_lte/icons.json @@ -34,7 +34,7 @@ }, "select": { "preferred_network_mode": { - "default": "mdi:transmission-tower" + "default": "mdi:antenna" } }, "switch": { diff --git a/homeassistant/components/huawei_lte/sensor.py b/homeassistant/components/huawei_lte/sensor.py index 3543433ca45..12588786b2b 100644 --- a/homeassistant/components/huawei_lte/sensor.py +++ b/homeassistant/components/huawei_lte/sensor.py @@ -181,7 +181,7 @@ SENSOR_META: dict[str, HuaweiSensorGroup] = { "cell_id": HuaweiSensorEntityDescription( key="cell_id", translation_key="cell_id", - icon="mdi:transmission-tower", + icon="mdi:antenna", entity_category=EntityCategory.DIAGNOSTIC, ), "cqi0": HuaweiSensorEntityDescription( @@ -230,6 +230,7 @@ SENSOR_META: dict[str, HuaweiSensorGroup] = { "enodeb_id": HuaweiSensorEntityDescription( key="enodeb_id", translation_key="enodeb_id", + icon="mdi:antenna", entity_category=EntityCategory.DIAGNOSTIC, ), "lac": HuaweiSensorEntityDescription( @@ -364,7 +365,7 @@ SENSOR_META: dict[str, HuaweiSensorGroup] = { "pci": HuaweiSensorEntityDescription( key="pci", translation_key="pci", - icon="mdi:transmission-tower", + icon="mdi:antenna", entity_category=EntityCategory.DIAGNOSTIC, ), "plmn": HuaweiSensorEntityDescription( From fc7f1ab42f0eb14f85dc8ca39b2fb54dcc893100 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=81lvaro=20Fern=C3=A1ndez=20Rojas?= Date: Mon, 21 Apr 2025 17:10:26 +0200 Subject: [PATCH 68/70] Update aioairzone-cloud to v0.6.12 (#143400) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Álvaro Fernández Rojas --- homeassistant/components/airzone_cloud/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/airzone_cloud/manifest.json b/homeassistant/components/airzone_cloud/manifest.json index 3b6f94df57c..ecc9634f36a 100644 --- a/homeassistant/components/airzone_cloud/manifest.json +++ b/homeassistant/components/airzone_cloud/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/airzone_cloud", "iot_class": "cloud_push", "loggers": ["aioairzone_cloud"], - "requirements": ["aioairzone-cloud==0.6.11"] + "requirements": ["aioairzone-cloud==0.6.12"] } diff --git a/requirements_all.txt b/requirements_all.txt index 0bf5966b0d5..ef17c5ec97e 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -179,7 +179,7 @@ aioacaia==0.1.14 aioairq==0.4.4 # homeassistant.components.airzone_cloud -aioairzone-cloud==0.6.11 +aioairzone-cloud==0.6.12 # homeassistant.components.airzone aioairzone==1.0.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index ea91ad28008..a0ef0deb8eb 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -167,7 +167,7 @@ aioacaia==0.1.14 aioairq==0.4.4 # homeassistant.components.airzone_cloud -aioairzone-cloud==0.6.11 +aioairzone-cloud==0.6.12 # homeassistant.components.airzone aioairzone==1.0.0 From 7030000348a08e76189a624402f9ad808e77c032 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Mon, 21 Apr 2025 17:20:57 +0200 Subject: [PATCH 69/70] Remove deprecated yaml import from Smarty (#143406) remove deprecated yaml import --- homeassistant/components/smarty/__init__.py | 76 +------------------ .../components/smarty/config_flow.py | 16 +--- homeassistant/components/smarty/entity.py | 2 +- homeassistant/components/smarty/strings.json | 14 ---- tests/components/smarty/conftest.py | 2 +- tests/components/smarty/test_config_flow.py | 53 +------------ tests/components/smarty/test_init.py | 59 +------------- 7 files changed, 10 insertions(+), 212 deletions(-) diff --git a/homeassistant/components/smarty/__init__.py b/homeassistant/components/smarty/__init__.py index aab8c6ab3c7..1803f501dc7 100644 --- a/homeassistant/components/smarty/__init__.py +++ b/homeassistant/components/smarty/__init__.py @@ -1,34 +1,10 @@ """Support to control a Salda Smarty XP/XV ventilation unit.""" -import ipaddress -import logging +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant -import voluptuous as vol - -from homeassistant.config_entries import SOURCE_IMPORT -from homeassistant.const import CONF_HOST, CONF_NAME, Platform -from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant -from homeassistant.data_entry_flow import FlowResultType -from homeassistant.helpers import config_validation as cv, issue_registry as ir -from homeassistant.helpers.typing import ConfigType - -from .const import DOMAIN from .coordinator import SmartyConfigEntry, SmartyCoordinator -_LOGGER = logging.getLogger(__name__) - -CONFIG_SCHEMA = vol.Schema( - { - DOMAIN: vol.Schema( - { - vol.Required(CONF_HOST): vol.All(ipaddress.ip_address, cv.string), - vol.Optional(CONF_NAME, default="Smarty"): cv.string, - } - ) - }, - extra=vol.ALLOW_EXTRA, -) - PLATFORMS = [ Platform.BINARY_SENSOR, Platform.BUTTON, @@ -38,54 +14,6 @@ PLATFORMS = [ ] -async def async_setup(hass: HomeAssistant, hass_config: ConfigType) -> bool: - """Create a smarty system.""" - if config := hass_config.get(DOMAIN): - hass.async_create_task(_async_import(hass, config)) - return True - - -async def _async_import(hass: HomeAssistant, config: ConfigType) -> None: - """Set up the smarty environment.""" - - if not hass.config_entries.async_entries(DOMAIN): - # Start import flow - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_IMPORT}, data=config - ) - if result["type"] == FlowResultType.ABORT: - ir.async_create_issue( - hass, - DOMAIN, - f"deprecated_yaml_import_issue_{result['reason']}", - breaks_in_ha_version="2025.5.0", - is_fixable=False, - issue_domain=DOMAIN, - severity=ir.IssueSeverity.WARNING, - translation_key=f"deprecated_yaml_import_issue_{result['reason']}", - translation_placeholders={ - "domain": DOMAIN, - "integration_title": "Smarty", - }, - ) - return - - ir.async_create_issue( - hass, - HOMEASSISTANT_DOMAIN, - f"deprecated_yaml_{DOMAIN}", - breaks_in_ha_version="2025.5.0", - is_fixable=False, - issue_domain=DOMAIN, - severity=ir.IssueSeverity.WARNING, - translation_key="deprecated_yaml", - translation_placeholders={ - "domain": DOMAIN, - "integration_title": "Smarty", - }, - ) - - async def async_setup_entry(hass: HomeAssistant, entry: SmartyConfigEntry) -> bool: """Set up the Smarty environment from a config entry.""" diff --git a/homeassistant/components/smarty/config_flow.py b/homeassistant/components/smarty/config_flow.py index a7f0bdd4123..5abae121cd7 100644 --- a/homeassistant/components/smarty/config_flow.py +++ b/homeassistant/components/smarty/config_flow.py @@ -7,7 +7,7 @@ from pysmarty2 import Smarty import voluptuous as vol from homeassistant.config_entries import ConfigFlow, ConfigFlowResult -from homeassistant.const import CONF_HOST, CONF_NAME +from homeassistant.const import CONF_HOST from .const import DOMAIN @@ -50,17 +50,3 @@ class SmartyConfigFlow(ConfigFlow, domain=DOMAIN): data_schema=vol.Schema({vol.Required(CONF_HOST): str}), errors=errors, ) - - async def async_step_import( - self, import_config: dict[str, Any] - ) -> ConfigFlowResult: - """Handle a flow initialized by import.""" - error = await self.hass.async_add_executor_job( - self._test_connection, import_config[CONF_HOST] - ) - if not error: - return self.async_create_entry( - title=import_config[CONF_NAME], - data={CONF_HOST: import_config[CONF_HOST]}, - ) - return self.async_abort(reason=error) diff --git a/homeassistant/components/smarty/entity.py b/homeassistant/components/smarty/entity.py index d26b56d489f..f6533000f45 100644 --- a/homeassistant/components/smarty/entity.py +++ b/homeassistant/components/smarty/entity.py @@ -3,7 +3,7 @@ from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.update_coordinator import CoordinatorEntity -from . import DOMAIN +from .const import DOMAIN from .coordinator import SmartyCoordinator diff --git a/homeassistant/components/smarty/strings.json b/homeassistant/components/smarty/strings.json index 341a300a26e..d9852ab40d3 100644 --- a/homeassistant/components/smarty/strings.json +++ b/homeassistant/components/smarty/strings.json @@ -20,20 +20,6 @@ "unknown": "[%key:common::config_flow::error::unknown%]" } }, - "issues": { - "deprecated_yaml_import_issue_unknown": { - "title": "YAML import failed with unknown error", - "description": "Configuring {integration_title} using YAML is being removed but there was an unknown error while importing your existing configuration.\nSetup will not proceed.\n\nVerify that your {integration_title} is operating correctly and restart Home Assistant to attempt the import again.\n\nAlternatively, you may remove the `{domain}` configuration from your configuration.yaml entirely, restart Home Assistant, and add the {integration_title} integration manually." - }, - "deprecated_yaml_import_issue_auth_error": { - "title": "YAML import failed due to an authentication error", - "description": "Configuring {integration_title} using YAML is being removed but there was an authentication error while importing your existing configuration.\nSetup will not proceed.\n\nVerify that your {integration_title} is operating correctly and restart Home Assistant to attempt the import again.\n\nAlternatively, you may remove the `{domain}` configuration from your configuration.yaml entirely, restart Home Assistant, and add the {integration_title} integration manually." - }, - "deprecated_yaml_import_issue_cannot_connect": { - "title": "YAML import failed due to a connection error", - "description": "Configuring {integration_title} using YAML is being removed but there was a connect error while importing your existing configuration.\nSetup will not proceed.\n\nVerify that your {integration_title} is operating correctly and restart Home Assistant to attempt the import again.\n\nAlternatively, you may remove the `{domain}` configuration from your configuration.yaml entirely, restart Home Assistant, and add the {integration_title} integration manually." - } - }, "entity": { "binary_sensor": { "alarm": { diff --git a/tests/components/smarty/conftest.py b/tests/components/smarty/conftest.py index a9b518d88f4..fe2fb4c7bab 100644 --- a/tests/components/smarty/conftest.py +++ b/tests/components/smarty/conftest.py @@ -5,7 +5,7 @@ from unittest.mock import AsyncMock, patch import pytest -from homeassistant.components.smarty import DOMAIN +from homeassistant.components.smarty.const import DOMAIN from homeassistant.const import CONF_HOST from tests.common import MockConfigEntry diff --git a/tests/components/smarty/test_config_flow.py b/tests/components/smarty/test_config_flow.py index fad4f27ca1c..831aca52c73 100644 --- a/tests/components/smarty/test_config_flow.py +++ b/tests/components/smarty/test_config_flow.py @@ -3,8 +3,8 @@ from unittest.mock import AsyncMock from homeassistant.components.smarty.const import DOMAIN -from homeassistant.config_entries import SOURCE_IMPORT, SOURCE_USER -from homeassistant.const import CONF_HOST, CONF_NAME +from homeassistant.config_entries import SOURCE_USER +from homeassistant.const import CONF_HOST from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType @@ -114,52 +114,3 @@ async def test_existing_entry( assert result["type"] is FlowResultType.ABORT assert result["reason"] == "already_configured" - - -async def test_import_flow( - hass: HomeAssistant, mock_smarty: AsyncMock, mock_setup_entry: AsyncMock -) -> None: - """Test the import flow.""" - - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_IMPORT}, - data={CONF_HOST: "192.168.0.2", CONF_NAME: "Smarty"}, - ) - assert result["type"] is FlowResultType.CREATE_ENTRY - assert result["title"] == "Smarty" - assert result["data"] == {CONF_HOST: "192.168.0.2"} - - assert len(mock_setup_entry.mock_calls) == 1 - - -async def test_import_cannot_connect( - hass: HomeAssistant, mock_smarty: AsyncMock -) -> None: - """Test we handle cannot connect error.""" - - mock_smarty.update.return_value = False - - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_IMPORT}, - data={CONF_HOST: "192.168.0.2", CONF_NAME: "Smarty"}, - ) - assert result["type"] is FlowResultType.ABORT - assert result["reason"] == "cannot_connect" - - -async def test_import_unknown_error( - hass: HomeAssistant, mock_smarty: AsyncMock -) -> None: - """Test we handle unknown error.""" - - mock_smarty.update.side_effect = Exception - - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_IMPORT}, - data={CONF_HOST: "192.168.0.2", CONF_NAME: "Smarty"}, - ) - assert result["type"] is FlowResultType.ABORT - assert result["reason"] == "unknown" diff --git a/tests/components/smarty/test_init.py b/tests/components/smarty/test_init.py index 0366ea9eade..6468fd74507 100644 --- a/tests/components/smarty/test_init.py +++ b/tests/components/smarty/test_init.py @@ -4,68 +4,15 @@ from unittest.mock import AsyncMock from syrupy import SnapshotAssertion -from homeassistant.components.smarty import DOMAIN -from homeassistant.const import CONF_HOST, CONF_NAME -from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant -from homeassistant.helpers import device_registry as dr, issue_registry as ir -from homeassistant.setup import async_setup_component +from homeassistant.components.smarty.const import DOMAIN +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr from . import setup_integration from tests.common import MockConfigEntry -async def test_import_flow( - hass: HomeAssistant, - mock_smarty: AsyncMock, - issue_registry: ir.IssueRegistry, - mock_setup_entry: AsyncMock, -) -> None: - """Test import flow.""" - assert await async_setup_component( - hass, DOMAIN, {DOMAIN: {CONF_HOST: "192.168.0.2", CONF_NAME: "smarty"}} - ) - await hass.async_block_till_done() - assert len(hass.config_entries.async_entries(DOMAIN)) == 1 - assert (HOMEASSISTANT_DOMAIN, "deprecated_yaml_smarty") in issue_registry.issues - - -async def test_import_flow_already_exists( - hass: HomeAssistant, - mock_smarty: AsyncMock, - issue_registry: ir.IssueRegistry, - mock_setup_entry: AsyncMock, - mock_config_entry: MockConfigEntry, -) -> None: - """Test import flow when entry already exists.""" - mock_config_entry.add_to_hass(hass) - assert await async_setup_component( - hass, DOMAIN, {DOMAIN: {CONF_HOST: "192.168.0.2", CONF_NAME: "smarty"}} - ) - await hass.async_block_till_done() - assert len(hass.config_entries.async_entries(DOMAIN)) == 1 - assert (HOMEASSISTANT_DOMAIN, "deprecated_yaml_smarty") in issue_registry.issues - - -async def test_import_flow_error( - hass: HomeAssistant, - mock_smarty: AsyncMock, - issue_registry: ir.IssueRegistry, - mock_setup_entry: AsyncMock, -) -> None: - """Test import flow when error occurs.""" - mock_smarty.update.return_value = False - assert await async_setup_component( - hass, DOMAIN, {DOMAIN: {CONF_HOST: "192.168.0.2", CONF_NAME: "smarty"}} - ) - await hass.async_block_till_done() - assert len(hass.config_entries.async_entries(DOMAIN)) == 0 - assert ( - DOMAIN, - "deprecated_yaml_import_issue_cannot_connect", - ) in issue_registry.issues - - async def test_device( hass: HomeAssistant, snapshot: SnapshotAssertion, From 80f34620c847387f721abf9dfe7aaeff3d824244 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Mon, 21 Apr 2025 20:24:02 +0200 Subject: [PATCH 70/70] Use common state for "Error" in `peblar` (#143273) --- homeassistant/components/peblar/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/peblar/strings.json b/homeassistant/components/peblar/strings.json index 416f1a2c062..9d88892fef1 100644 --- a/homeassistant/components/peblar/strings.json +++ b/homeassistant/components/peblar/strings.json @@ -108,7 +108,7 @@ "name": "State", "state": { "charging": "[%key:common::state::charging%]", - "error": "Error", + "error": "[%key:common::state::error%]", "fault": "Fault", "invalid": "Invalid", "no_ev_connected": "No EV connected",