From b9dcf89b3766b0f478ac1ef764b2e926876d53a0 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Fri, 12 Sep 2025 11:53:08 -0400 Subject: [PATCH 01/30] Fix hassfest error for internal integrations (#152173) Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- script/hassfest/quality_scale.py | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/script/hassfest/quality_scale.py b/script/hassfest/quality_scale.py index 2c34cf36c88cf2..a3a0f9d6facb06 100644 --- a/script/hassfest/quality_scale.py +++ b/script/hassfest/quality_scale.py @@ -2293,7 +2293,11 @@ def validate_iqs_file(config: Config, integration: Integration) -> None: ): integration.add_error( "quality_scale", - "Quality scale definition not found. New integrations are required to at least reach the Bronze tier.", + ( + "New integrations marked as internal should be added to INTEGRATIONS_WITHOUT_SCALE in script/hassfest/quality_scale.py." + if integration.quality_scale == "internal" + else "Quality scale definition not found. New integrations are required to at least reach the Bronze tier." + ), ) return if declared_quality_scale is not None: @@ -2338,7 +2342,11 @@ def validate_iqs_file(config: Config, integration: Integration) -> None: ): integration.add_error( "quality_scale", - "New integrations are required to at least reach the Bronze tier.", + ( + "New integrations marked as internal should be added to INTEGRATIONS_WITHOUT_SCALE in script/hassfest/quality_scale.py." + if integration.quality_scale == "internal" + else "New integrations are required to at least reach the Bronze tier." + ), ) return name = str(iqs_file) From 69893aba4be7c13d3f0c76d7c11482010c89cf6e Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Fri, 12 Sep 2025 18:01:16 +0200 Subject: [PATCH 02/30] Update frontend to 20250903.5 (#152170) --- homeassistant/components/frontend/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/frontend/manifest.json b/homeassistant/components/frontend/manifest.json index d74bf1f30b7f96..44dff45029936e 100644 --- a/homeassistant/components/frontend/manifest.json +++ b/homeassistant/components/frontend/manifest.json @@ -20,5 +20,5 @@ "documentation": "https://www.home-assistant.io/integrations/frontend", "integration_type": "system", "quality_scale": "internal", - "requirements": ["home-assistant-frontend==20250903.3"] + "requirements": ["home-assistant-frontend==20250903.5"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index dee918c3f66cec..c1d7b581e3fc50 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -38,7 +38,7 @@ habluetooth==5.6.2 hass-nabucasa==1.1.1 hassil==3.2.0 home-assistant-bluetooth==1.13.1 -home-assistant-frontend==20250903.3 +home-assistant-frontend==20250903.5 home-assistant-intents==2025.9.3 httpx==0.28.1 ifaddr==0.2.0 diff --git a/requirements_all.txt b/requirements_all.txt index d875524f13e0c6..245ef0bccf2eac 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1181,7 +1181,7 @@ hole==0.9.0 holidays==0.79 # homeassistant.components.frontend -home-assistant-frontend==20250903.3 +home-assistant-frontend==20250903.5 # homeassistant.components.conversation home-assistant-intents==2025.9.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 531a4fee327632..5de30897170abd 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1030,7 +1030,7 @@ hole==0.9.0 holidays==0.79 # homeassistant.components.frontend -home-assistant-frontend==20250903.3 +home-assistant-frontend==20250903.5 # homeassistant.components.conversation home-assistant-intents==2025.9.3 From 1f4c0b3e9b14008caf06ccfb730e4b09d391ce45 Mon Sep 17 00:00:00 2001 From: jan iversen Date: Fri, 12 Sep 2025 18:04:01 +0200 Subject: [PATCH 03/30] Add codeowner for Modbus (#152163) --- CODEOWNERS | 2 ++ homeassistant/components/modbus/manifest.json | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/CODEOWNERS b/CODEOWNERS index c4ce561fdb62dc..bc3fd1b495f6f1 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -970,6 +970,8 @@ build.json @home-assistant/supervisor /tests/components/moat/ @bdraco /homeassistant/components/mobile_app/ @home-assistant/core /tests/components/mobile_app/ @home-assistant/core +/homeassistant/components/modbus/ @janiversen +/tests/components/modbus/ @janiversen /homeassistant/components/modem_callerid/ @tkdrob /tests/components/modem_callerid/ @tkdrob /homeassistant/components/modern_forms/ @wonderslug diff --git a/homeassistant/components/modbus/manifest.json b/homeassistant/components/modbus/manifest.json index 32a043c43793a4..0bcaf67cd13235 100644 --- a/homeassistant/components/modbus/manifest.json +++ b/homeassistant/components/modbus/manifest.json @@ -1,7 +1,7 @@ { "domain": "modbus", "name": "Modbus", - "codeowners": [], + "codeowners": ["@janiversen"], "documentation": "https://www.home-assistant.io/integrations/modbus", "iot_class": "local_polling", "loggers": ["pymodbus"], From d324021a3f8db46f4f0fc51b3b637ce548365e12 Mon Sep 17 00:00:00 2001 From: Iskra kranj <162285659+iskrakranj@users.noreply.github.com> Date: Fri, 12 Sep 2025 18:05:37 +0200 Subject: [PATCH 04/30] Bump pyiskra to 0.1.27 (#152160) --- homeassistant/components/iskra/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/iskra/manifest.json b/homeassistant/components/iskra/manifest.json index e378a1442d2e18..ce1a3e670a2586 100644 --- a/homeassistant/components/iskra/manifest.json +++ b/homeassistant/components/iskra/manifest.json @@ -7,5 +7,5 @@ "integration_type": "hub", "iot_class": "local_polling", "loggers": ["pyiskra"], - "requirements": ["pyiskra==0.1.26"] + "requirements": ["pyiskra==0.1.27"] } diff --git a/requirements_all.txt b/requirements_all.txt index 245ef0bccf2eac..1e3877ae0b3007 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2067,7 +2067,7 @@ pyiqvia==2022.04.0 pyirishrail==0.0.2 # homeassistant.components.iskra -pyiskra==0.1.26 +pyiskra==0.1.27 # homeassistant.components.iss pyiss==1.0.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 5de30897170abd..12a50fd8b026f4 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1721,7 +1721,7 @@ pyipp==0.17.0 pyiqvia==2022.04.0 # homeassistant.components.iskra -pyiskra==0.1.26 +pyiskra==0.1.27 # homeassistant.components.iss pyiss==1.0.1 From 984590c6d10cd61b28129618e95b255e07b560f5 Mon Sep 17 00:00:00 2001 From: Simone Chemelli Date: Fri, 12 Sep 2025 18:18:27 +0200 Subject: [PATCH 05/30] Fix Pylance errors in UptimeRobot tests (#152185) --- tests/components/uptimerobot/common.py | 8 ++-- .../uptimerobot/test_binary_sensor.py | 7 ++-- .../uptimerobot/test_config_flow.py | 6 +++ tests/components/uptimerobot/test_init.py | 42 +++++++++---------- tests/components/uptimerobot/test_sensor.py | 7 ++-- tests/components/uptimerobot/test_switch.py | 13 +++--- 6 files changed, 43 insertions(+), 40 deletions(-) diff --git a/tests/components/uptimerobot/common.py b/tests/components/uptimerobot/common.py index 01f003327c1252..7a404e3d877ca1 100644 --- a/tests/components/uptimerobot/common.py +++ b/tests/components/uptimerobot/common.py @@ -80,7 +80,7 @@ class MockApiResponseKey(str, Enum): def mock_uptimerobot_api_response( - data: dict[str, Any] + data: list[dict[str, Any]] | list[UptimeRobotMonitor] | UptimeRobotAccount | UptimeRobotApiError @@ -115,8 +115,10 @@ async def setup_uptimerobot_integration(hass: HomeAssistant) -> MockConfigEntry: assert await hass.config_entries.async_setup(mock_entry.entry_id) await hass.async_block_till_done() - assert hass.states.get(UPTIMEROBOT_BINARY_SENSOR_TEST_ENTITY).state == STATE_ON - assert hass.states.get(UPTIMEROBOT_SENSOR_TEST_ENTITY).state == STATE_UP + assert (entity := hass.states.get(UPTIMEROBOT_BINARY_SENSOR_TEST_ENTITY)) + assert entity.state == STATE_ON + assert (entity := hass.states.get(UPTIMEROBOT_SENSOR_TEST_ENTITY)) + assert entity.state == STATE_UP assert mock_entry.state is ConfigEntryState.LOADED return mock_entry diff --git a/tests/components/uptimerobot/test_binary_sensor.py b/tests/components/uptimerobot/test_binary_sensor.py index 3de9b9ec399630..c214a7d15434d7 100644 --- a/tests/components/uptimerobot/test_binary_sensor.py +++ b/tests/components/uptimerobot/test_binary_sensor.py @@ -26,8 +26,7 @@ async def test_presentation(hass: HomeAssistant) -> None: """Test the presenstation of UptimeRobot binary_sensors.""" await setup_uptimerobot_integration(hass) - entity = hass.states.get(UPTIMEROBOT_BINARY_SENSOR_TEST_ENTITY) - + assert (entity := hass.states.get(UPTIMEROBOT_BINARY_SENSOR_TEST_ENTITY)) assert entity.state == STATE_ON assert entity.attributes["device_class"] == BinarySensorDeviceClass.CONNECTIVITY assert entity.attributes["attribution"] == ATTRIBUTION @@ -38,7 +37,7 @@ 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) + assert (entity := hass.states.get(UPTIMEROBOT_BINARY_SENSOR_TEST_ENTITY)) assert entity.state == STATE_ON with patch( @@ -48,5 +47,5 @@ async def test_unavailable_on_update_failure(hass: HomeAssistant) -> None: async_fire_time_changed(hass, dt_util.utcnow() + COORDINATOR_UPDATE_INTERVAL) await hass.async_block_till_done() - entity = hass.states.get(UPTIMEROBOT_BINARY_SENSOR_TEST_ENTITY) + assert (entity := hass.states.get(UPTIMEROBOT_BINARY_SENSOR_TEST_ENTITY)) assert entity.state == STATE_UNAVAILABLE diff --git a/tests/components/uptimerobot/test_config_flow.py b/tests/components/uptimerobot/test_config_flow.py index 621d9cc27c3865..ce6ec7cfcf7c69 100644 --- a/tests/components/uptimerobot/test_config_flow.py +++ b/tests/components/uptimerobot/test_config_flow.py @@ -80,6 +80,7 @@ async def test_user_key_read_only(hass: HomeAssistant) -> None: await hass.async_block_till_done() assert result2["type"] is FlowResultType.FORM + assert result2["errors"] assert result2["errors"]["base"] == "not_main_key" @@ -107,6 +108,7 @@ async def test_exception_thrown(hass: HomeAssistant, exception, error_key) -> No ) assert result2["type"] is FlowResultType.FORM + assert result2["errors"] assert result2["errors"]["base"] == error_key @@ -125,6 +127,7 @@ async def test_api_error(hass: HomeAssistant, caplog: pytest.LogCaptureFixture) {CONF_API_KEY: MOCK_UPTIMEROBOT_API_KEY}, ) + assert result2["errors"] assert result2["errors"]["base"] == "unknown" assert "test error from API." in caplog.text @@ -227,6 +230,7 @@ async def test_reauthentication_failure( assert result2["step_id"] == "reauth_confirm" assert result2["type"] is FlowResultType.FORM + assert result2["errors"] assert result2["errors"]["base"] == "unknown" @@ -299,6 +303,7 @@ async def test_reauthentication_failure_account_not_matching( assert result2["step_id"] == "reauth_confirm" assert result2["type"] is FlowResultType.FORM + assert result2["errors"] assert result2["errors"]["base"] == "reauth_failed_matching_account" @@ -374,6 +379,7 @@ async def test_reconfigure_failed( ) assert result2["type"] is FlowResultType.FORM + assert result2["errors"] assert result2["errors"]["base"] == "invalid_api_key" new_key = "u0242ac120003-new" diff --git a/tests/components/uptimerobot/test_init.py b/tests/components/uptimerobot/test_init.py index 435b0737c6db5a..d252501aa28bba 100644 --- a/tests/components/uptimerobot/test_init.py +++ b/tests/components/uptimerobot/test_init.py @@ -102,7 +102,7 @@ async def test_reauthentication_trigger_after_setup( """Test reauthentication trigger.""" mock_config_entry = await setup_uptimerobot_integration(hass) - binary_sensor = hass.states.get(UPTIMEROBOT_BINARY_SENSOR_TEST_ENTITY) + assert (binary_sensor := hass.states.get(UPTIMEROBOT_BINARY_SENSOR_TEST_ENTITY)) assert mock_config_entry.state is ConfigEntryState.LOADED assert binary_sensor.state == STATE_ON @@ -115,10 +115,8 @@ async def test_reauthentication_trigger_after_setup( await hass.async_block_till_done() flows = hass.config_entries.flow.async_progress() - assert ( - hass.states.get(UPTIMEROBOT_BINARY_SENSOR_TEST_ENTITY).state - == STATE_UNAVAILABLE - ) + assert (entity := hass.states.get(UPTIMEROBOT_BINARY_SENSOR_TEST_ENTITY)) + assert entity.state == STATE_UNAVAILABLE assert "Authentication failed while fetching uptimerobot data" in caplog.text @@ -146,9 +144,10 @@ async def test_integration_reload( async_fire_time_changed(hass) await hass.async_block_till_done() - entry = hass.config_entries.async_get_entry(mock_entry.entry_id) + assert (entry := hass.config_entries.async_get_entry(mock_entry.entry_id)) assert entry.state is ConfigEntryState.LOADED - assert hass.states.get(UPTIMEROBOT_BINARY_SENSOR_TEST_ENTITY).state == STATE_ON + assert (entity := hass.states.get(UPTIMEROBOT_BINARY_SENSOR_TEST_ENTITY)) + assert entity.state == STATE_ON async def test_update_errors( @@ -166,10 +165,8 @@ async def test_update_errors( freezer.tick(COORDINATOR_UPDATE_INTERVAL) async_fire_time_changed(hass) await hass.async_block_till_done() - assert ( - hass.states.get(UPTIMEROBOT_BINARY_SENSOR_TEST_ENTITY).state - == STATE_UNAVAILABLE - ) + assert (entity := hass.states.get(UPTIMEROBOT_BINARY_SENSOR_TEST_ENTITY)) + assert entity.state == STATE_UNAVAILABLE with patch( "pyuptimerobot.UptimeRobot.async_get_monitors", @@ -178,7 +175,8 @@ async def test_update_errors( freezer.tick(COORDINATOR_UPDATE_INTERVAL) async_fire_time_changed(hass) await hass.async_block_till_done() - assert hass.states.get(UPTIMEROBOT_BINARY_SENSOR_TEST_ENTITY).state == STATE_ON + assert (entity := hass.states.get(UPTIMEROBOT_BINARY_SENSOR_TEST_ENTITY)) + assert entity.state == STATE_ON with patch( "pyuptimerobot.UptimeRobot.async_get_monitors", @@ -187,10 +185,8 @@ async def test_update_errors( freezer.tick(COORDINATOR_UPDATE_INTERVAL) async_fire_time_changed(hass) await hass.async_block_till_done() - assert ( - hass.states.get(UPTIMEROBOT_BINARY_SENSOR_TEST_ENTITY).state - == STATE_UNAVAILABLE - ) + assert (entity := hass.states.get(UPTIMEROBOT_BINARY_SENSOR_TEST_ENTITY)) + assert entity.state == STATE_UNAVAILABLE assert "Error fetching uptimerobot data: test error from API" in caplog.text @@ -209,7 +205,8 @@ async def test_device_management( assert devices[0].identifiers == {(DOMAIN, "1234")} assert devices[0].name == "Test monitor" - assert hass.states.get(UPTIMEROBOT_BINARY_SENSOR_TEST_ENTITY).state == STATE_ON + assert (entity := hass.states.get(UPTIMEROBOT_BINARY_SENSOR_TEST_ENTITY)) + assert entity.state == STATE_ON assert hass.states.get(f"{UPTIMEROBOT_BINARY_SENSOR_TEST_ENTITY}_2") is None with patch( @@ -227,10 +224,10 @@ async def test_device_management( assert devices[0].identifiers == {(DOMAIN, "1234")} assert devices[1].identifiers == {(DOMAIN, "12345")} - assert hass.states.get(UPTIMEROBOT_BINARY_SENSOR_TEST_ENTITY).state == STATE_ON - assert ( - hass.states.get(f"{UPTIMEROBOT_BINARY_SENSOR_TEST_ENTITY}_2").state == STATE_ON - ) + assert (entity := hass.states.get(UPTIMEROBOT_BINARY_SENSOR_TEST_ENTITY)) + assert entity.state == STATE_ON + assert (entity2 := hass.states.get(f"{UPTIMEROBOT_BINARY_SENSOR_TEST_ENTITY}_2")) + assert entity2.state == STATE_ON with patch( "pyuptimerobot.UptimeRobot.async_get_monitors", @@ -244,5 +241,6 @@ async def test_device_management( assert len(devices) == 1 assert devices[0].identifiers == {(DOMAIN, "1234")} - assert hass.states.get(UPTIMEROBOT_BINARY_SENSOR_TEST_ENTITY).state == STATE_ON + assert (entity := hass.states.get(UPTIMEROBOT_BINARY_SENSOR_TEST_ENTITY)) + assert entity.state == STATE_ON assert hass.states.get(f"{UPTIMEROBOT_BINARY_SENSOR_TEST_ENTITY}_2") is None diff --git a/tests/components/uptimerobot/test_sensor.py b/tests/components/uptimerobot/test_sensor.py index 8cee33c1052f55..15e0b0ba1316cc 100644 --- a/tests/components/uptimerobot/test_sensor.py +++ b/tests/components/uptimerobot/test_sensor.py @@ -24,8 +24,7 @@ async def test_presentation(hass: HomeAssistant) -> None: """Test the presentation of UptimeRobot sensors.""" await setup_uptimerobot_integration(hass) - entity = hass.states.get(UPTIMEROBOT_SENSOR_TEST_ENTITY) - + assert (entity := hass.states.get(UPTIMEROBOT_SENSOR_TEST_ENTITY)) is not None assert entity.state == STATE_UP assert entity.attributes["target"] == MOCK_UPTIMEROBOT_MONITOR["url"] assert entity.attributes["device_class"] == SensorDeviceClass.ENUM @@ -42,7 +41,7 @@ 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_SENSOR_TEST_ENTITY) + assert (entity := hass.states.get(UPTIMEROBOT_SENSOR_TEST_ENTITY)) is not None assert entity.state == STATE_UP with patch( @@ -52,5 +51,5 @@ async def test_unavailable_on_update_failure(hass: HomeAssistant) -> None: async_fire_time_changed(hass, dt_util.utcnow() + COORDINATOR_UPDATE_INTERVAL) await hass.async_block_till_done() - entity = hass.states.get(UPTIMEROBOT_SENSOR_TEST_ENTITY) + assert (entity := hass.states.get(UPTIMEROBOT_SENSOR_TEST_ENTITY)) is not None assert entity.state == STATE_UNAVAILABLE diff --git a/tests/components/uptimerobot/test_switch.py b/tests/components/uptimerobot/test_switch.py index 48e9da05720b5c..a88158ea76558f 100644 --- a/tests/components/uptimerobot/test_switch.py +++ b/tests/components/uptimerobot/test_switch.py @@ -33,8 +33,7 @@ async def test_presentation(hass: HomeAssistant) -> None: """Test the presentation of UptimeRobot switches.""" await setup_uptimerobot_integration(hass) - entity = hass.states.get(UPTIMEROBOT_SWITCH_TEST_ENTITY) - + assert (entity := hass.states.get(UPTIMEROBOT_SWITCH_TEST_ENTITY)) is not None assert entity.state == STATE_ON assert entity.attributes["target"] == MOCK_UPTIMEROBOT_MONITOR["url"] @@ -67,7 +66,7 @@ async def test_switch_off(hass: HomeAssistant) -> None: blocking=True, ) - entity = hass.states.get(UPTIMEROBOT_SWITCH_TEST_ENTITY) + assert (entity := hass.states.get(UPTIMEROBOT_SWITCH_TEST_ENTITY)) is not None assert entity.state == STATE_OFF @@ -97,7 +96,7 @@ async def test_switch_on(hass: HomeAssistant) -> None: blocking=True, ) - entity = hass.states.get(UPTIMEROBOT_SWITCH_TEST_ENTITY) + assert (entity := hass.states.get(UPTIMEROBOT_SWITCH_TEST_ENTITY)) is not None assert entity.state == STATE_ON @@ -107,7 +106,7 @@ async def test_authentication_error( """Test authentication error turning switch on/off.""" await setup_uptimerobot_integration(hass) - entity = hass.states.get(UPTIMEROBOT_SWITCH_TEST_ENTITY) + assert (entity := hass.states.get(UPTIMEROBOT_SWITCH_TEST_ENTITY)) is not None assert entity.state == STATE_ON with ( @@ -133,7 +132,7 @@ async def test_action_execution_failure(hass: HomeAssistant) -> None: """Test turning switch on/off failure.""" await setup_uptimerobot_integration(hass) - entity = hass.states.get(UPTIMEROBOT_SWITCH_TEST_ENTITY) + assert (entity := hass.states.get(UPTIMEROBOT_SWITCH_TEST_ENTITY)) is not None assert entity.state == STATE_ON with ( @@ -161,7 +160,7 @@ async def test_switch_api_failure(hass: HomeAssistant) -> None: """Test general exception turning switch on/off.""" await setup_uptimerobot_integration(hass) - entity = hass.states.get(UPTIMEROBOT_SWITCH_TEST_ENTITY) + assert (entity := hass.states.get(UPTIMEROBOT_SWITCH_TEST_ENTITY)) is not None assert entity.state == STATE_ON with patch( From f0dc1f927b76dff34fb499adb34aeb4d4e03194c Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Fri, 12 Sep 2025 18:19:03 +0200 Subject: [PATCH 06/30] Fix ai_task generate image service test (#152184) --- tests/components/ai_task/test_init.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/components/ai_task/test_init.py b/tests/components/ai_task/test_init.py index e89e4cea670721..5c6465936d91f9 100644 --- a/tests/components/ai_task/test_init.py +++ b/tests/components/ai_task/test_init.py @@ -316,7 +316,7 @@ async def test_generate_image_service( assert "image_data" not in result assert result["media_source_id"].startswith("media-source://ai_task/images/") - assert result["url"].startswith("http://10.10.10.10:8123/api/ai_task/images/") + assert result["url"].startswith("/api/ai_task/images/") assert result["mime_type"] == "image/png" assert result["model"] == "mock_model" assert result["revised_prompt"] == "mock_revised_prompt" From bd8ddd7cd8ba260bee616daa8779df984e6dc06b Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Fri, 12 Sep 2025 18:29:47 +0200 Subject: [PATCH 07/30] Register androidtv entity services in async_setup (#152172) --- .../components/androidtv/__init__.py | 12 ++++ .../components/androidtv/media_player.py | 40 +---------- .../components/androidtv/services.py | 66 +++++++++++++++++++ .../components/androidtv/test_media_player.py | 2 +- 4 files changed, 80 insertions(+), 40 deletions(-) create mode 100644 homeassistant/components/androidtv/services.py diff --git a/homeassistant/components/androidtv/__init__.py b/homeassistant/components/androidtv/__init__.py index 4ffa0e24777d81..a5637053e4a48c 100644 --- a/homeassistant/components/androidtv/__init__.py +++ b/homeassistant/components/androidtv/__init__.py @@ -33,9 +33,11 @@ ) from homeassistant.core import Event, HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.device_registry import format_mac from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.helpers.storage import STORAGE_DIR +from homeassistant.helpers.typing import ConfigType from .const import ( CONF_ADB_SERVER_IP, @@ -46,10 +48,12 @@ DEFAULT_ADB_SERVER_PORT, DEVICE_ANDROIDTV, DEVICE_FIRETV, + DOMAIN, PROP_ETHMAC, PROP_WIFIMAC, SIGNAL_CONFIG_ENTITY, ) +from .services import async_setup_services ADB_PYTHON_EXCEPTIONS: tuple = ( AdbTimeoutError, @@ -63,6 +67,8 @@ ) ADB_TCP_EXCEPTIONS: tuple = (ConnectionResetError, RuntimeError) +CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) + PLATFORMS = [Platform.MEDIA_PLAYER, Platform.REMOTE] RELOAD_OPTIONS = [CONF_STATE_DETECTION_RULES] @@ -188,6 +194,12 @@ async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return True +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: + """Set up the Android TV / Fire TV integration.""" + async_setup_services(hass) + return True + + async def async_setup_entry(hass: HomeAssistant, entry: AndroidTVConfigEntry) -> bool: """Set up Android Debug Bridge platform.""" diff --git a/homeassistant/components/androidtv/media_player.py b/homeassistant/components/androidtv/media_player.py index 6a60d84e39ee47..9621282208e1e6 100644 --- a/homeassistant/components/androidtv/media_player.py +++ b/homeassistant/components/androidtv/media_player.py @@ -8,7 +8,6 @@ from androidtv.constants import APPS, KEYS from androidtv.setup_async import AndroidTVAsync, FireTVAsync -import voluptuous as vol from homeassistant.components import persistent_notification from homeassistant.components.media_player import ( @@ -17,9 +16,7 @@ MediaPlayerEntityFeature, MediaPlayerState, ) -from homeassistant.const import ATTR_COMMAND from homeassistant.core import HomeAssistant -from homeassistant.helpers import config_validation as cv, entity_platform from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.util.dt import utcnow @@ -39,19 +36,10 @@ SIGNAL_CONFIG_ENTITY, ) from .entity import AndroidTVEntity, adb_decorator +from .services import ATTR_ADB_RESPONSE, ATTR_HDMI_INPUT, SERVICE_LEARN_SENDEVENT _LOGGER = logging.getLogger(__name__) -ATTR_ADB_RESPONSE = "adb_response" -ATTR_DEVICE_PATH = "device_path" -ATTR_HDMI_INPUT = "hdmi_input" -ATTR_LOCAL_PATH = "local_path" - -SERVICE_ADB_COMMAND = "adb_command" -SERVICE_DOWNLOAD = "download" -SERVICE_LEARN_SENDEVENT = "learn_sendevent" -SERVICE_UPLOAD = "upload" - # Translate from `AndroidTV` / `FireTV` reported state to HA state. ANDROIDTV_STATES = { "off": MediaPlayerState.OFF, @@ -77,32 +65,6 @@ async def async_setup_entry( ] ) - platform = entity_platform.async_get_current_platform() - platform.async_register_entity_service( - SERVICE_ADB_COMMAND, - {vol.Required(ATTR_COMMAND): cv.string}, - "adb_command", - ) - platform.async_register_entity_service( - SERVICE_LEARN_SENDEVENT, None, "learn_sendevent" - ) - platform.async_register_entity_service( - SERVICE_DOWNLOAD, - { - vol.Required(ATTR_DEVICE_PATH): cv.string, - vol.Required(ATTR_LOCAL_PATH): cv.string, - }, - "service_download", - ) - platform.async_register_entity_service( - SERVICE_UPLOAD, - { - vol.Required(ATTR_DEVICE_PATH): cv.string, - vol.Required(ATTR_LOCAL_PATH): cv.string, - }, - "service_upload", - ) - class ADBDevice(AndroidTVEntity, MediaPlayerEntity): """Representation of an Android or Fire TV device.""" diff --git a/homeassistant/components/androidtv/services.py b/homeassistant/components/androidtv/services.py new file mode 100644 index 00000000000000..8a44399b727468 --- /dev/null +++ b/homeassistant/components/androidtv/services.py @@ -0,0 +1,66 @@ +"""Services for Android/Fire TV devices.""" + +from __future__ import annotations + +import voluptuous as vol + +from homeassistant.components.media_player import DOMAIN as MEDIA_PLAYER_DOMAIN +from homeassistant.const import ATTR_COMMAND +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import config_validation as cv, service + +from .const import DOMAIN + +ATTR_ADB_RESPONSE = "adb_response" +ATTR_DEVICE_PATH = "device_path" +ATTR_HDMI_INPUT = "hdmi_input" +ATTR_LOCAL_PATH = "local_path" + +SERVICE_ADB_COMMAND = "adb_command" +SERVICE_DOWNLOAD = "download" +SERVICE_LEARN_SENDEVENT = "learn_sendevent" +SERVICE_UPLOAD = "upload" + + +@callback +def async_setup_services(hass: HomeAssistant) -> None: + """Register the Android TV / Fire TV services.""" + + service.async_register_platform_entity_service( + hass, + DOMAIN, + SERVICE_ADB_COMMAND, + entity_domain=MEDIA_PLAYER_DOMAIN, + schema={vol.Required(ATTR_COMMAND): cv.string}, + func="adb_command", + ) + service.async_register_platform_entity_service( + hass, + DOMAIN, + SERVICE_LEARN_SENDEVENT, + entity_domain=MEDIA_PLAYER_DOMAIN, + schema=None, + func="learn_sendevent", + ) + service.async_register_platform_entity_service( + hass, + DOMAIN, + SERVICE_DOWNLOAD, + entity_domain=MEDIA_PLAYER_DOMAIN, + schema={ + vol.Required(ATTR_DEVICE_PATH): cv.string, + vol.Required(ATTR_LOCAL_PATH): cv.string, + }, + func="service_download", + ) + service.async_register_platform_entity_service( + hass, + DOMAIN, + SERVICE_UPLOAD, + entity_domain=MEDIA_PLAYER_DOMAIN, + schema={ + vol.Required(ATTR_DEVICE_PATH): cv.string, + vol.Required(ATTR_LOCAL_PATH): cv.string, + }, + func="service_upload", + ) diff --git a/tests/components/androidtv/test_media_player.py b/tests/components/androidtv/test_media_player.py index efc05772a9aec3..2588f61177f5f3 100644 --- a/tests/components/androidtv/test_media_player.py +++ b/tests/components/androidtv/test_media_player.py @@ -21,7 +21,7 @@ DEFAULT_PORT, DOMAIN, ) -from homeassistant.components.androidtv.media_player import ( +from homeassistant.components.androidtv.services import ( ATTR_DEVICE_PATH, ATTR_LOCAL_PATH, SERVICE_ADB_COMMAND, From 3713c03c078bc6c20f9f2cc03b5d093e373c7bbc Mon Sep 17 00:00:00 2001 From: laiho-vogels <144690720+laiho-vogels@users.noreply.github.com> Date: Fri, 12 Sep 2025 18:33:26 +0200 Subject: [PATCH 08/30] Drop index from preset name in MotionMount (#151301) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Abílio Costa --- homeassistant/components/motionmount/select.py | 14 +++++++++++--- homeassistant/components/motionmount/strings.json | 2 +- 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/motionmount/select.py b/homeassistant/components/motionmount/select.py index 861faa319cd504..d02b286c2962ff 100644 --- a/homeassistant/components/motionmount/select.py +++ b/homeassistant/components/motionmount/select.py @@ -36,6 +36,7 @@ class MotionMountPresets(MotionMountEntity, SelectEntity): _attr_should_poll = True _attr_translation_key = "motionmount_preset" + _name_to_index: dict[str, int] def __init__( self, @@ -50,8 +51,12 @@ def __init__( def _update_options(self, presets: list[motionmount.Preset]) -> None: """Convert presets to select options.""" - options = [f"{preset.index}: {preset.name}" for preset in presets] - options.insert(0, WALL_PRESET_NAME) + # Ordered list of options (wall first, then presets) + options = [WALL_PRESET_NAME] + [preset.name for preset in presets] + + # Build mapping name → index (wall = 0) + self._name_to_index = {WALL_PRESET_NAME: 0} + self._name_to_index.update({preset.name: preset.index for preset in presets}) self._attr_options = options @@ -123,7 +128,10 @@ def current_option(self) -> str | None: async def async_select_option(self, option: str) -> None: """Set the new option.""" - index = int(option[:1]) + index = self._name_to_index.get(option) + if index is None: + raise HomeAssistantError(f"Unknown preset selected: {option}") + try: await self.mm.go_to_preset(index) except (TimeoutError, socket.gaierror) as ex: diff --git a/homeassistant/components/motionmount/strings.json b/homeassistant/components/motionmount/strings.json index 2c951a7aefef5e..8d079dd777d51e 100644 --- a/homeassistant/components/motionmount/strings.json +++ b/homeassistant/components/motionmount/strings.json @@ -83,7 +83,7 @@ "motionmount_preset": { "name": "Preset", "state": { - "0_wall": "0: Wall" + "0_wall": "Wall" } } } From 09381abf46bcf3826c8658ae44de87d4e2216f06 Mon Sep 17 00:00:00 2001 From: Maciej Bieniek Date: Fri, 12 Sep 2025 18:34:56 +0200 Subject: [PATCH 09/30] Add hourly forecast for AccuWeather integration (#152178) --- .../components/accuweather/__init__.py | 27 +- homeassistant/components/accuweather/const.py | 1 + .../components/accuweather/coordinator.py | 78 +- .../components/accuweather/weather.py | 37 +- tests/components/accuweather/conftest.py | 6 +- ...ast_data.json => daily_forecast_data.json} | 0 .../fixtures/hourly_forecast_data.json | 1334 +++++++++++++++++ .../accuweather/snapshots/test_weather.ambr | 182 ++- tests/components/accuweather/test_weather.py | 10 +- 9 files changed, 1638 insertions(+), 37 deletions(-) rename tests/components/accuweather/fixtures/{forecast_data.json => daily_forecast_data.json} (100%) create mode 100644 tests/components/accuweather/fixtures/hourly_forecast_data.json diff --git a/homeassistant/components/accuweather/__init__.py b/homeassistant/components/accuweather/__init__.py index c046933d5d5d21..bb453c67f57f82 100644 --- a/homeassistant/components/accuweather/__init__.py +++ b/homeassistant/components/accuweather/__init__.py @@ -2,21 +2,23 @@ from __future__ import annotations +import asyncio import logging from accuweather import AccuWeather from homeassistant.components.sensor import DOMAIN as SENSOR_PLATFORM -from homeassistant.const import CONF_API_KEY, CONF_NAME, Platform +from homeassistant.const import CONF_API_KEY, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er from homeassistant.helpers.aiohttp_client import async_get_clientsession -from .const import DOMAIN, UPDATE_INTERVAL_DAILY_FORECAST, UPDATE_INTERVAL_OBSERVATION +from .const import DOMAIN from .coordinator import ( AccuWeatherConfigEntry, AccuWeatherDailyForecastDataUpdateCoordinator, AccuWeatherData, + AccuWeatherHourlyForecastDataUpdateCoordinator, AccuWeatherObservationDataUpdateCoordinator, ) @@ -28,7 +30,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: AccuWeatherConfigEntry) -> bool: """Set up AccuWeather as config entry.""" api_key: str = entry.data[CONF_API_KEY] - name: str = entry.data[CONF_NAME] location_key = entry.unique_id @@ -41,26 +42,28 @@ async def async_setup_entry(hass: HomeAssistant, entry: AccuWeatherConfigEntry) hass, entry, accuweather, - name, - "observation", - UPDATE_INTERVAL_OBSERVATION, ) - coordinator_daily_forecast = AccuWeatherDailyForecastDataUpdateCoordinator( hass, entry, accuweather, - name, - "daily forecast", - UPDATE_INTERVAL_DAILY_FORECAST, + ) + coordinator_hourly_forecast = AccuWeatherHourlyForecastDataUpdateCoordinator( + hass, + entry, + accuweather, ) - await coordinator_observation.async_config_entry_first_refresh() - await coordinator_daily_forecast.async_config_entry_first_refresh() + await asyncio.gather( + coordinator_observation.async_config_entry_first_refresh(), + coordinator_daily_forecast.async_config_entry_first_refresh(), + coordinator_hourly_forecast.async_config_entry_first_refresh(), + ) entry.runtime_data = AccuWeatherData( coordinator_observation=coordinator_observation, coordinator_daily_forecast=coordinator_daily_forecast, + coordinator_hourly_forecast=coordinator_hourly_forecast, ) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) diff --git a/homeassistant/components/accuweather/const.py b/homeassistant/components/accuweather/const.py index b9bf8df4556188..a487e95582cd26 100644 --- a/homeassistant/components/accuweather/const.py +++ b/homeassistant/components/accuweather/const.py @@ -71,3 +71,4 @@ } UPDATE_INTERVAL_OBSERVATION = timedelta(minutes=10) UPDATE_INTERVAL_DAILY_FORECAST = timedelta(hours=6) +UPDATE_INTERVAL_HOURLY_FORECAST = timedelta(hours=30) diff --git a/homeassistant/components/accuweather/coordinator.py b/homeassistant/components/accuweather/coordinator.py index 780c977f9305a5..7056c6e81fdb8c 100644 --- a/homeassistant/components/accuweather/coordinator.py +++ b/homeassistant/components/accuweather/coordinator.py @@ -3,6 +3,7 @@ from __future__ import annotations from asyncio import timeout +from collections.abc import Awaitable, Callable from dataclasses import dataclass from datetime import timedelta import logging @@ -12,6 +13,7 @@ from aiohttp.client_exceptions import ClientConnectorError from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_NAME from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.update_coordinator import ( @@ -20,7 +22,13 @@ UpdateFailed, ) -from .const import DOMAIN, MANUFACTURER +from .const import ( + DOMAIN, + MANUFACTURER, + UPDATE_INTERVAL_DAILY_FORECAST, + UPDATE_INTERVAL_HOURLY_FORECAST, + UPDATE_INTERVAL_OBSERVATION, +) EXCEPTIONS = (ApiError, ClientConnectorError, InvalidApiKeyError, RequestsExceededError) @@ -33,6 +41,7 @@ class AccuWeatherData: coordinator_observation: AccuWeatherObservationDataUpdateCoordinator coordinator_daily_forecast: AccuWeatherDailyForecastDataUpdateCoordinator + coordinator_hourly_forecast: AccuWeatherHourlyForecastDataUpdateCoordinator type AccuWeatherConfigEntry = ConfigEntry[AccuWeatherData] @@ -48,13 +57,11 @@ def __init__( hass: HomeAssistant, config_entry: AccuWeatherConfigEntry, accuweather: AccuWeather, - name: str, - coordinator_type: str, - update_interval: timedelta, ) -> None: """Initialize.""" self.accuweather = accuweather self.location_key = accuweather.location_key + name = config_entry.data[CONF_NAME] if TYPE_CHECKING: assert self.location_key is not None @@ -65,8 +72,8 @@ def __init__( hass, _LOGGER, config_entry=config_entry, - name=f"{name} ({coordinator_type})", - update_interval=update_interval, + name=f"{name} (observation)", + update_interval=UPDATE_INTERVAL_OBSERVATION, ) async def _async_update_data(self) -> dict[str, Any]: @@ -86,23 +93,25 @@ async def _async_update_data(self) -> dict[str, Any]: return result -class AccuWeatherDailyForecastDataUpdateCoordinator( +class AccuWeatherForecastDataUpdateCoordinator( TimestampDataUpdateCoordinator[list[dict[str, Any]]] ): - """Class to manage fetching AccuWeather data API.""" + """Base class for AccuWeather forecast.""" def __init__( self, hass: HomeAssistant, config_entry: AccuWeatherConfigEntry, accuweather: AccuWeather, - name: str, coordinator_type: str, update_interval: timedelta, + fetch_method: Callable[..., Awaitable[list[dict[str, Any]]]], ) -> None: """Initialize.""" self.accuweather = accuweather self.location_key = accuweather.location_key + self._fetch_method = fetch_method + name = config_entry.data[CONF_NAME] if TYPE_CHECKING: assert self.location_key is not None @@ -118,12 +127,10 @@ def __init__( ) async def _async_update_data(self) -> list[dict[str, Any]]: - """Update data via library.""" + """Update forecast data via library.""" try: async with timeout(10): - result = await self.accuweather.async_get_daily_forecast( - language=self.hass.config.language - ) + result = await self._fetch_method(language=self.hass.config.language) except EXCEPTIONS as error: raise UpdateFailed( translation_domain=DOMAIN, @@ -132,10 +139,53 @@ async def _async_update_data(self) -> list[dict[str, Any]]: ) from error _LOGGER.debug("Requests remaining: %d", self.accuweather.requests_remaining) - return result +class AccuWeatherDailyForecastDataUpdateCoordinator( + AccuWeatherForecastDataUpdateCoordinator +): + """Coordinator for daily forecast.""" + + def __init__( + self, + hass: HomeAssistant, + config_entry: AccuWeatherConfigEntry, + accuweather: AccuWeather, + ) -> None: + """Initialize.""" + super().__init__( + hass, + config_entry, + accuweather, + "daily forecast", + UPDATE_INTERVAL_DAILY_FORECAST, + fetch_method=accuweather.async_get_daily_forecast, + ) + + +class AccuWeatherHourlyForecastDataUpdateCoordinator( + AccuWeatherForecastDataUpdateCoordinator +): + """Coordinator for hourly forecast.""" + + def __init__( + self, + hass: HomeAssistant, + config_entry: AccuWeatherConfigEntry, + accuweather: AccuWeather, + ) -> None: + """Initialize.""" + super().__init__( + hass, + config_entry, + accuweather, + "hourly forecast", + UPDATE_INTERVAL_HOURLY_FORECAST, + fetch_method=accuweather.async_get_hourly_forecast, + ) + + def _get_device_info(location_key: str, name: str) -> DeviceInfo: """Get device info.""" return DeviceInfo( diff --git a/homeassistant/components/accuweather/weather.py b/homeassistant/components/accuweather/weather.py index 770f2b64f2040c..25d6297cee686b 100644 --- a/homeassistant/components/accuweather/weather.py +++ b/homeassistant/components/accuweather/weather.py @@ -45,6 +45,7 @@ AccuWeatherConfigEntry, AccuWeatherDailyForecastDataUpdateCoordinator, AccuWeatherData, + AccuWeatherHourlyForecastDataUpdateCoordinator, AccuWeatherObservationDataUpdateCoordinator, ) @@ -64,6 +65,7 @@ class AccuWeatherEntity( CoordinatorWeatherEntity[ AccuWeatherObservationDataUpdateCoordinator, AccuWeatherDailyForecastDataUpdateCoordinator, + AccuWeatherHourlyForecastDataUpdateCoordinator, ] ): """Define an AccuWeather entity.""" @@ -76,6 +78,7 @@ def __init__(self, accuweather_data: AccuWeatherData) -> None: super().__init__( observation_coordinator=accuweather_data.coordinator_observation, daily_coordinator=accuweather_data.coordinator_daily_forecast, + hourly_coordinator=accuweather_data.coordinator_hourly_forecast, ) self._attr_native_precipitation_unit = UnitOfPrecipitationDepth.MILLIMETERS @@ -86,10 +89,13 @@ def __init__(self, accuweather_data: AccuWeatherData) -> None: self._attr_unique_id = accuweather_data.coordinator_observation.location_key self._attr_attribution = ATTRIBUTION self._attr_device_info = accuweather_data.coordinator_observation.device_info - self._attr_supported_features = WeatherEntityFeature.FORECAST_DAILY + self._attr_supported_features = ( + WeatherEntityFeature.FORECAST_DAILY | WeatherEntityFeature.FORECAST_HOURLY + ) self.observation_coordinator = accuweather_data.coordinator_observation self.daily_coordinator = accuweather_data.coordinator_daily_forecast + self.hourly_coordinator = accuweather_data.coordinator_hourly_forecast @property def condition(self) -> str | None: @@ -207,3 +213,32 @@ def _async_forecast_daily(self) -> list[Forecast] | None: } for item in self.daily_coordinator.data ] + + @callback + def _async_forecast_hourly(self) -> list[Forecast] | None: + """Return the hourly forecast in native units.""" + return [ + { + ATTR_FORECAST_TIME: utc_from_timestamp( + item["EpochDateTime"] + ).isoformat(), + ATTR_FORECAST_CLOUD_COVERAGE: item["CloudCover"], + ATTR_FORECAST_HUMIDITY: item["RelativeHumidity"], + ATTR_FORECAST_NATIVE_TEMP: item["Temperature"][ATTR_VALUE], + ATTR_FORECAST_NATIVE_APPARENT_TEMP: item["RealFeelTemperature"][ + ATTR_VALUE + ], + ATTR_FORECAST_NATIVE_PRECIPITATION: item["TotalLiquid"][ATTR_VALUE], + ATTR_FORECAST_PRECIPITATION_PROBABILITY: item[ + "PrecipitationProbability" + ], + ATTR_FORECAST_NATIVE_WIND_SPEED: item["Wind"][ATTR_SPEED][ATTR_VALUE], + ATTR_FORECAST_NATIVE_WIND_GUST_SPEED: item["WindGust"][ATTR_SPEED][ + ATTR_VALUE + ], + ATTR_FORECAST_UV_INDEX: item["UVIndex"], + ATTR_FORECAST_WIND_BEARING: item["Wind"][ATTR_DIRECTION]["Degrees"], + ATTR_FORECAST_CONDITION: CONDITION_MAP.get(item["WeatherIcon"]), + } + for item in self.hourly_coordinator.data + ] diff --git a/tests/components/accuweather/conftest.py b/tests/components/accuweather/conftest.py index 737fd3f84b66e3..abecc7cc198eea 100644 --- a/tests/components/accuweather/conftest.py +++ b/tests/components/accuweather/conftest.py @@ -14,7 +14,8 @@ def mock_accuweather_client() -> Generator[AsyncMock]: """Mock a AccuWeather client.""" current = load_json_object_fixture("current_conditions_data.json", DOMAIN) - forecast = load_json_array_fixture("forecast_data.json", DOMAIN) + daily_forecast = load_json_array_fixture("daily_forecast_data.json", DOMAIN) + hourly_forecast = load_json_array_fixture("hourly_forecast_data.json", DOMAIN) location = load_json_object_fixture("location_data.json", DOMAIN) with ( @@ -29,7 +30,8 @@ def mock_accuweather_client() -> Generator[AsyncMock]: client = mock_client.return_value client.async_get_location.return_value = location client.async_get_current_conditions.return_value = current - client.async_get_daily_forecast.return_value = forecast + client.async_get_daily_forecast.return_value = daily_forecast + client.async_get_hourly_forecast.return_value = hourly_forecast client.location_key = "0123456" client.requests_remaining = 10 diff --git a/tests/components/accuweather/fixtures/forecast_data.json b/tests/components/accuweather/fixtures/daily_forecast_data.json similarity index 100% rename from tests/components/accuweather/fixtures/forecast_data.json rename to tests/components/accuweather/fixtures/daily_forecast_data.json diff --git a/tests/components/accuweather/fixtures/hourly_forecast_data.json b/tests/components/accuweather/fixtures/hourly_forecast_data.json new file mode 100644 index 00000000000000..43a04d533a143c --- /dev/null +++ b/tests/components/accuweather/fixtures/hourly_forecast_data.json @@ -0,0 +1,1334 @@ +[ + { + "DateTime": "2025-09-12t16:00:00+02:00", + "EpochDateTime": 1757685600, + "WeatherIcon": 2, + "IconPhrase": "przewa\u017cnie s\u0142onecznie", + "HasPrecipitation": false, + "IsDaylight": true, + "Temperature": { + "Value": 22.5, + "Unit": "C", + "UnitType": 17 + }, + "RealFeelTemperature": { + "Value": 22.6, + "Unit": "C", + "UnitType": 17, + "Phrase": "Przyjemnie" + }, + "RealFeelTemperatureShade": { + "Value": 20.8, + "Unit": "C", + "UnitType": 17, + "Phrase": "Przyjemnie" + }, + "WetBulbTemperature": { + "Value": 15.6, + "Unit": "C", + "UnitType": 17 + }, + "WetBulbGlobeTemperature": { + "Value": 19.4, + "Unit": "C", + "UnitType": 17 + }, + "DewPoint": { + "Value": 11.1, + "Unit": "C", + "UnitType": 17 + }, + "Wind": { + "Speed": { + "Value": 14.8, + "Unit": "km/h", + "UnitType": 7 + }, + "Direction": { + "Degrees": 239, + "Localized": "WSW", + "English": "WSW" + } + }, + "WindGust": { + "Speed": { + "Value": 24.1, + "Unit": "km/h", + "UnitType": 7 + } + }, + "RelativeHumidity": 48, + "IndoorRelativeHumidity": 48, + "Visibility": { + "Value": 16.1, + "Unit": "km", + "UnitType": 6 + }, + "Ceiling": { + "Value": 10058.0, + "Unit": "m", + "UnitType": 5 + }, + "UVIndex": 2, + "UVIndexFloat": 2.4, + "UVIndexText": "niskie", + "PrecipitationProbability": 1, + "ThunderstormProbability": 0, + "RainProbability": 1, + "SnowProbability": 0, + "IceProbability": 0, + "TotalLiquid": { + "Value": 0.0, + "Unit": "mm", + "UnitType": 3 + }, + "Rain": { + "Value": 0.0, + "Unit": "mm", + "UnitType": 3 + }, + "Snow": { + "Value": 0.0, + "Unit": "cm", + "UnitType": 4 + }, + "Ice": { + "Value": 0.0, + "Unit": "mm", + "UnitType": 3 + }, + "CloudCover": 13, + "Evapotranspiration": { + "Value": 0.3, + "Unit": "mm", + "UnitType": 3 + }, + "SolarIrradiance": { + "Value": 525.5, + "Unit": "W/m\u00b2", + "UnitType": 33 + }, + "AccuLumenBrightnessIndex": 9.0 + }, + { + "DateTime": "2025-09-12t17:00:00+02:00", + "EpochDateTime": 1757689200, + "WeatherIcon": 2, + "IconPhrase": "przewa\u017cnie s\u0142onecznie", + "HasPrecipitation": false, + "IsDaylight": true, + "Temperature": { + "Value": 23.1, + "Unit": "C", + "UnitType": 17 + }, + "RealFeelTemperature": { + "Value": 22.9, + "Unit": "C", + "UnitType": 17, + "Phrase": "Przyjemnie" + }, + "RealFeelTemperatureShade": { + "Value": 21.6, + "Unit": "C", + "UnitType": 17, + "Phrase": "Przyjemnie" + }, + "WetBulbTemperature": { + "Value": 16.2, + "Unit": "C", + "UnitType": 17 + }, + "WetBulbGlobeTemperature": { + "Value": 20.1, + "Unit": "C", + "UnitType": 17 + }, + "DewPoint": { + "Value": 11.7, + "Unit": "C", + "UnitType": 17 + }, + "Wind": { + "Speed": { + "Value": 13.0, + "Unit": "km/h", + "UnitType": 7 + }, + "Direction": { + "Degrees": 238, + "Localized": "WSW", + "English": "WSW" + } + }, + "WindGust": { + "Speed": { + "Value": 22.2, + "Unit": "km/h", + "UnitType": 7 + } + }, + "RelativeHumidity": 48, + "IndoorRelativeHumidity": 48, + "Visibility": { + "Value": 16.1, + "Unit": "km", + "UnitType": 6 + }, + "Ceiling": { + "Value": 9144.0, + "Unit": "m", + "UnitType": 5 + }, + "UVIndex": 2, + "UVIndexFloat": 1.7, + "UVIndexText": "niskie", + "PrecipitationProbability": 1, + "ThunderstormProbability": 0, + "RainProbability": 1, + "SnowProbability": 0, + "IceProbability": 0, + "TotalLiquid": { + "Value": 0.0, + "Unit": "mm", + "UnitType": 3 + }, + "Rain": { + "Value": 0.0, + "Unit": "mm", + "UnitType": 3 + }, + "Snow": { + "Value": 0.0, + "Unit": "cm", + "UnitType": 4 + }, + "Ice": { + "Value": 0.0, + "Unit": "mm", + "UnitType": 3 + }, + "CloudCover": 17, + "Evapotranspiration": { + "Value": 0.3, + "Unit": "mm", + "UnitType": 3 + }, + "SolarIrradiance": { + "Value": 386.6, + "Unit": "W/m\u00b2", + "UnitType": 33 + }, + "AccuLumenBrightnessIndex": 9.0 + }, + { + "DateTime": "2025-09-12t18:00:00+02:00", + "EpochDateTime": 1757692800, + "WeatherIcon": 2, + "IconPhrase": "przewa\u017cnie s\u0142onecznie", + "HasPrecipitation": false, + "IsDaylight": true, + "Temperature": { + "Value": 21.3, + "Unit": "C", + "UnitType": 17 + }, + "RealFeelTemperature": { + "Value": 20.6, + "Unit": "C", + "UnitType": 17, + "Phrase": "Przyjemnie" + }, + "RealFeelTemperatureShade": { + "Value": 20.6, + "Unit": "C", + "UnitType": 17, + "Phrase": "Przyjemnie" + }, + "WetBulbTemperature": { + "Value": 15.8, + "Unit": "C", + "UnitType": 17 + }, + "WetBulbGlobeTemperature": { + "Value": 19.1, + "Unit": "C", + "UnitType": 17 + }, + "DewPoint": { + "Value": 12.3, + "Unit": "C", + "UnitType": 17 + }, + "Wind": { + "Speed": { + "Value": 13.0, + "Unit": "km/h", + "UnitType": 7 + }, + "Direction": { + "Degrees": 232, + "Localized": "SW", + "English": "SW" + } + }, + "WindGust": { + "Speed": { + "Value": 18.5, + "Unit": "km/h", + "UnitType": 7 + } + }, + "RelativeHumidity": 56, + "IndoorRelativeHumidity": 56, + "Visibility": { + "Value": 16.1, + "Unit": "km", + "UnitType": 6 + }, + "Ceiling": { + "Value": 9144.0, + "Unit": "m", + "UnitType": 5 + }, + "UVIndex": 1, + "UVIndexFloat": 1.0, + "UVIndexText": "niskie", + "PrecipitationProbability": 1, + "ThunderstormProbability": 0, + "RainProbability": 1, + "SnowProbability": 0, + "IceProbability": 0, + "TotalLiquid": { + "Value": 0.0, + "Unit": "mm", + "UnitType": 3 + }, + "Rain": { + "Value": 0.0, + "Unit": "mm", + "UnitType": 3 + }, + "Snow": { + "Value": 0.0, + "Unit": "cm", + "UnitType": 4 + }, + "Ice": { + "Value": 0.0, + "Unit": "mm", + "UnitType": 3 + }, + "CloudCover": 23, + "Evapotranspiration": { + "Value": 0.3, + "Unit": "mm", + "UnitType": 3 + }, + "SolarIrradiance": { + "Value": 224.7, + "Unit": "W/m\u00b2", + "UnitType": 33 + }, + "AccuLumenBrightnessIndex": 7.0 + }, + { + "DateTime": "2025-09-12t19:00:00+02:00", + "EpochDateTime": 1757696400, + "WeatherIcon": 2, + "IconPhrase": "przewa\u017cnie s\u0142onecznie", + "HasPrecipitation": false, + "IsDaylight": true, + "Temperature": { + "Value": 19.5, + "Unit": "C", + "UnitType": 17 + }, + "RealFeelTemperature": { + "Value": 18.2, + "Unit": "C", + "UnitType": 17, + "Phrase": "Przyjemnie" + }, + "RealFeelTemperatureShade": { + "Value": 18.2, + "Unit": "C", + "UnitType": 17, + "Phrase": "Przyjemnie" + }, + "WetBulbTemperature": { + "Value": 15.4, + "Unit": "C", + "UnitType": 17 + }, + "WetBulbGlobeTemperature": { + "Value": 17.9, + "Unit": "C", + "UnitType": 17 + }, + "DewPoint": { + "Value": 12.2, + "Unit": "C", + "UnitType": 17 + }, + "Wind": { + "Speed": { + "Value": 13.0, + "Unit": "km/h", + "UnitType": 7 + }, + "Direction": { + "Degrees": 224, + "Localized": "SW", + "English": "SW" + } + }, + "WindGust": { + "Speed": { + "Value": 16.7, + "Unit": "km/h", + "UnitType": 7 + } + }, + "RelativeHumidity": 62, + "IndoorRelativeHumidity": 60, + "Visibility": { + "Value": 16.1, + "Unit": "km", + "UnitType": 6 + }, + "Ceiling": { + "Value": 9144.0, + "Unit": "m", + "UnitType": 5 + }, + "UVIndex": 0, + "UVIndexFloat": 0.2, + "UVIndexText": "niskie", + "PrecipitationProbability": 2, + "ThunderstormProbability": 0, + "RainProbability": 2, + "SnowProbability": 0, + "IceProbability": 0, + "TotalLiquid": { + "Value": 0.0, + "Unit": "mm", + "UnitType": 3 + }, + "Rain": { + "Value": 0.0, + "Unit": "mm", + "UnitType": 3 + }, + "Snow": { + "Value": 0.0, + "Unit": "cm", + "UnitType": 4 + }, + "Ice": { + "Value": 0.0, + "Unit": "mm", + "UnitType": 3 + }, + "CloudCover": 29, + "Evapotranspiration": { + "Value": 0.0, + "Unit": "mm", + "UnitType": 3 + }, + "SolarIrradiance": { + "Value": 52.2, + "Unit": "W/m\u00b2", + "UnitType": 33 + }, + "AccuLumenBrightnessIndex": 2.0 + }, + { + "DateTime": "2025-09-12t20:00:00+02:00", + "EpochDateTime": 1757700000, + "WeatherIcon": 35, + "IconPhrase": "zachmurzenie umiarkowane", + "HasPrecipitation": false, + "IsDaylight": false, + "Temperature": { + "Value": 17.7, + "Unit": "C", + "UnitType": 17 + }, + "RealFeelTemperature": { + "Value": 16.7, + "Unit": "C", + "UnitType": 17, + "Phrase": "Przyjemnie" + }, + "RealFeelTemperatureShade": { + "Value": 16.7, + "Unit": "C", + "UnitType": 17, + "Phrase": "Przyjemnie" + }, + "WetBulbTemperature": { + "Value": 14.6, + "Unit": "C", + "UnitType": 17 + }, + "WetBulbGlobeTemperature": { + "Value": 16.7, + "Unit": "C", + "UnitType": 17 + }, + "DewPoint": { + "Value": 12.1, + "Unit": "C", + "UnitType": 17 + }, + "Wind": { + "Speed": { + "Value": 11.1, + "Unit": "km/h", + "UnitType": 7 + }, + "Direction": { + "Degrees": 219, + "Localized": "SW", + "English": "SW" + } + }, + "WindGust": { + "Speed": { + "Value": 14.8, + "Unit": "km/h", + "UnitType": 7 + } + }, + "RelativeHumidity": 69, + "IndoorRelativeHumidity": 60, + "Visibility": { + "Value": 16.1, + "Unit": "km", + "UnitType": 6 + }, + "Ceiling": { + "Value": 9144.0, + "Unit": "m", + "UnitType": 5 + }, + "UVIndex": 0, + "UVIndexFloat": 0.0, + "UVIndexText": "niskie", + "PrecipitationProbability": 3, + "ThunderstormProbability": 0, + "RainProbability": 3, + "SnowProbability": 0, + "IceProbability": 0, + "TotalLiquid": { + "Value": 0.0, + "Unit": "mm", + "UnitType": 3 + }, + "Rain": { + "Value": 0.0, + "Unit": "mm", + "UnitType": 3 + }, + "Snow": { + "Value": 0.0, + "Unit": "cm", + "UnitType": 4 + }, + "Ice": { + "Value": 0.0, + "Unit": "mm", + "UnitType": 3 + }, + "CloudCover": 34, + "Evapotranspiration": { + "Value": 0.0, + "Unit": "mm", + "UnitType": 3 + }, + "SolarIrradiance": { + "Value": 0.0, + "Unit": "W/m\u00b2", + "UnitType": 33 + }, + "AccuLumenBrightnessIndex": 0.0 + }, + { + "DateTime": "2025-09-12t21:00:00+02:00", + "EpochDateTime": 1757703600, + "WeatherIcon": 35, + "IconPhrase": "zachmurzenie umiarkowane", + "HasPrecipitation": false, + "IsDaylight": false, + "Temperature": { + "Value": 15.8, + "Unit": "C", + "UnitType": 17 + }, + "RealFeelTemperature": { + "Value": 14.9, + "Unit": "C", + "UnitType": 17, + "Phrase": "Ch\u0142odno" + }, + "RealFeelTemperatureShade": { + "Value": 14.9, + "Unit": "C", + "UnitType": 17, + "Phrase": "Ch\u0142odno" + }, + "WetBulbTemperature": { + "Value": 13.7, + "Unit": "C", + "UnitType": 17 + }, + "WetBulbGlobeTemperature": { + "Value": 15.6, + "Unit": "C", + "UnitType": 17 + }, + "DewPoint": { + "Value": 11.9, + "Unit": "C", + "UnitType": 17 + }, + "Wind": { + "Speed": { + "Value": 11.1, + "Unit": "km/h", + "UnitType": 7 + }, + "Direction": { + "Degrees": 230, + "Localized": "SW", + "English": "SW" + } + }, + "WindGust": { + "Speed": { + "Value": 13.0, + "Unit": "km/h", + "UnitType": 7 + } + }, + "RelativeHumidity": 77, + "IndoorRelativeHumidity": 59, + "Visibility": { + "Value": 16.1, + "Unit": "km", + "UnitType": 6 + }, + "Ceiling": { + "Value": 9144.0, + "Unit": "m", + "UnitType": 5 + }, + "UVIndex": 0, + "UVIndexFloat": 0.0, + "UVIndexText": "niskie", + "PrecipitationProbability": 3, + "ThunderstormProbability": 0, + "RainProbability": 3, + "SnowProbability": 0, + "IceProbability": 0, + "TotalLiquid": { + "Value": 0.0, + "Unit": "mm", + "UnitType": 3 + }, + "Rain": { + "Value": 0.0, + "Unit": "mm", + "UnitType": 3 + }, + "Snow": { + "Value": 0.0, + "Unit": "cm", + "UnitType": 4 + }, + "Ice": { + "Value": 0.0, + "Unit": "mm", + "UnitType": 3 + }, + "CloudCover": 30, + "Evapotranspiration": { + "Value": 0.0, + "Unit": "mm", + "UnitType": 3 + }, + "SolarIrradiance": { + "Value": 0.0, + "Unit": "W/m\u00b2", + "UnitType": 33 + }, + "AccuLumenBrightnessIndex": 0.0 + }, + { + "DateTime": "2025-09-12t22:00:00+02:00", + "EpochDateTime": 1757707200, + "WeatherIcon": 34, + "IconPhrase": "przewa\u017cnie bezchmurnie", + "HasPrecipitation": false, + "IsDaylight": false, + "Temperature": { + "Value": 14.6, + "Unit": "C", + "UnitType": 17 + }, + "RealFeelTemperature": { + "Value": 13.8, + "Unit": "C", + "UnitType": 17, + "Phrase": "Ch\u0142odno" + }, + "RealFeelTemperatureShade": { + "Value": 13.8, + "Unit": "C", + "UnitType": 17, + "Phrase": "Ch\u0142odno" + }, + "WetBulbTemperature": { + "Value": 13.3, + "Unit": "C", + "UnitType": 17 + }, + "WetBulbGlobeTemperature": { + "Value": 14.9, + "Unit": "C", + "UnitType": 17 + }, + "DewPoint": { + "Value": 12.0, + "Unit": "C", + "UnitType": 17 + }, + "Wind": { + "Speed": { + "Value": 9.3, + "Unit": "km/h", + "UnitType": 7 + }, + "Direction": { + "Degrees": 259, + "Localized": "W", + "English": "W" + } + }, + "WindGust": { + "Speed": { + "Value": 13.0, + "Unit": "km/h", + "UnitType": 7 + } + }, + "RelativeHumidity": 84, + "IndoorRelativeHumidity": 60, + "Visibility": { + "Value": 16.1, + "Unit": "km", + "UnitType": 6 + }, + "Ceiling": { + "Value": 9144.0, + "Unit": "m", + "UnitType": 5 + }, + "UVIndex": 0, + "UVIndexFloat": 0.0, + "UVIndexText": "niskie", + "PrecipitationProbability": 3, + "ThunderstormProbability": 0, + "RainProbability": 3, + "SnowProbability": 0, + "IceProbability": 0, + "TotalLiquid": { + "Value": 0.0, + "Unit": "mm", + "UnitType": 3 + }, + "Rain": { + "Value": 0.0, + "Unit": "mm", + "UnitType": 3 + }, + "Snow": { + "Value": 0.0, + "Unit": "cm", + "UnitType": 4 + }, + "Ice": { + "Value": 0.0, + "Unit": "mm", + "UnitType": 3 + }, + "CloudCover": 26, + "Evapotranspiration": { + "Value": 0.0, + "Unit": "mm", + "UnitType": 3 + }, + "SolarIrradiance": { + "Value": 0.0, + "Unit": "W/m\u00b2", + "UnitType": 33 + }, + "AccuLumenBrightnessIndex": 0.0 + }, + { + "DateTime": "2025-09-12t23:00:00+02:00", + "EpochDateTime": 1757710800, + "WeatherIcon": 34, + "IconPhrase": "przewa\u017cnie bezchmurnie", + "HasPrecipitation": false, + "IsDaylight": false, + "Temperature": { + "Value": 14.4, + "Unit": "C", + "UnitType": 17 + }, + "RealFeelTemperature": { + "Value": 13.8, + "Unit": "C", + "UnitType": 17, + "Phrase": "Ch\u0142odno" + }, + "RealFeelTemperatureShade": { + "Value": 13.8, + "Unit": "C", + "UnitType": 17, + "Phrase": "Ch\u0142odno" + }, + "WetBulbTemperature": { + "Value": 13.3, + "Unit": "C", + "UnitType": 17 + }, + "WetBulbGlobeTemperature": { + "Value": 14.9, + "Unit": "C", + "UnitType": 17 + }, + "DewPoint": { + "Value": 12.2, + "Unit": "C", + "UnitType": 17 + }, + "Wind": { + "Speed": { + "Value": 9.3, + "Unit": "km/h", + "UnitType": 7 + }, + "Direction": { + "Degrees": 272, + "Localized": "W", + "English": "W" + } + }, + "WindGust": { + "Speed": { + "Value": 13.0, + "Unit": "km/h", + "UnitType": 7 + } + }, + "RelativeHumidity": 86, + "IndoorRelativeHumidity": 60, + "Visibility": { + "Value": 16.1, + "Unit": "km", + "UnitType": 6 + }, + "Ceiling": { + "Value": 9144.0, + "Unit": "m", + "UnitType": 5 + }, + "UVIndex": 0, + "UVIndexFloat": 0.0, + "UVIndexText": "niskie", + "PrecipitationProbability": 4, + "ThunderstormProbability": 0, + "RainProbability": 4, + "SnowProbability": 0, + "IceProbability": 0, + "TotalLiquid": { + "Value": 0.0, + "Unit": "mm", + "UnitType": 3 + }, + "Rain": { + "Value": 0.0, + "Unit": "mm", + "UnitType": 3 + }, + "Snow": { + "Value": 0.0, + "Unit": "cm", + "UnitType": 4 + }, + "Ice": { + "Value": 0.0, + "Unit": "mm", + "UnitType": 3 + }, + "CloudCover": 22, + "Evapotranspiration": { + "Value": 0.0, + "Unit": "mm", + "UnitType": 3 + }, + "SolarIrradiance": { + "Value": 0.0, + "Unit": "W/m\u00b2", + "UnitType": 33 + }, + "AccuLumenBrightnessIndex": 0.0 + }, + { + "DateTime": "2025-09-13t00:00:00+02:00", + "EpochDateTime": 1757714400, + "WeatherIcon": 35, + "IconPhrase": "zachmurzenie umiarkowane", + "HasPrecipitation": false, + "IsDaylight": false, + "Temperature": { + "Value": 13.9, + "Unit": "C", + "UnitType": 17 + }, + "RealFeelTemperature": { + "Value": 13.5, + "Unit": "C", + "UnitType": 17, + "Phrase": "Ch\u0142odno" + }, + "RealFeelTemperatureShade": { + "Value": 13.5, + "Unit": "C", + "UnitType": 17, + "Phrase": "Ch\u0142odno" + }, + "WetBulbTemperature": { + "Value": 13.0, + "Unit": "C", + "UnitType": 17 + }, + "WetBulbGlobeTemperature": { + "Value": 14.5, + "Unit": "C", + "UnitType": 17 + }, + "DewPoint": { + "Value": 12.2, + "Unit": "C", + "UnitType": 17 + }, + "Wind": { + "Speed": { + "Value": 7.4, + "Unit": "km/h", + "UnitType": 7 + }, + "Direction": { + "Degrees": 265, + "Localized": "W", + "English": "W" + } + }, + "WindGust": { + "Speed": { + "Value": 13.0, + "Unit": "km/h", + "UnitType": 7 + } + }, + "RelativeHumidity": 89, + "IndoorRelativeHumidity": 60, + "Visibility": { + "Value": 11.3, + "Unit": "km", + "UnitType": 6 + }, + "Ceiling": { + "Value": 9144.0, + "Unit": "m", + "UnitType": 5 + }, + "UVIndex": 0, + "UVIndexFloat": 0.0, + "UVIndexText": "niskie", + "PrecipitationProbability": 4, + "ThunderstormProbability": 0, + "RainProbability": 4, + "SnowProbability": 0, + "IceProbability": 0, + "TotalLiquid": { + "Value": 0.0, + "Unit": "mm", + "UnitType": 3 + }, + "Rain": { + "Value": 0.0, + "Unit": "mm", + "UnitType": 3 + }, + "Snow": { + "Value": 0.0, + "Unit": "cm", + "UnitType": 4 + }, + "Ice": { + "Value": 0.0, + "Unit": "mm", + "UnitType": 3 + }, + "CloudCover": 48, + "Evapotranspiration": { + "Value": 0.0, + "Unit": "mm", + "UnitType": 3 + }, + "SolarIrradiance": { + "Value": 0.0, + "Unit": "W/m\u00b2", + "UnitType": 33 + }, + "AccuLumenBrightnessIndex": 0.0 + }, + { + "DateTime": "2025-09-13t01:00:00+02:00", + "EpochDateTime": 1757718000, + "WeatherIcon": 36, + "IconPhrase": "przej\u015bciowe zachmurzenia", + "HasPrecipitation": false, + "IsDaylight": false, + "Temperature": { + "Value": 13.6, + "Unit": "C", + "UnitType": 17 + }, + "RealFeelTemperature": { + "Value": 13.2, + "Unit": "C", + "UnitType": 17, + "Phrase": "Ch\u0142odno" + }, + "RealFeelTemperatureShade": { + "Value": 13.2, + "Unit": "C", + "UnitType": 17, + "Phrase": "Ch\u0142odno" + }, + "WetBulbTemperature": { + "Value": 13.0, + "Unit": "C", + "UnitType": 17 + }, + "WetBulbGlobeTemperature": { + "Value": 14.0, + "Unit": "C", + "UnitType": 17 + }, + "DewPoint": { + "Value": 12.3, + "Unit": "C", + "UnitType": 17 + }, + "Wind": { + "Speed": { + "Value": 7.4, + "Unit": "km/h", + "UnitType": 7 + }, + "Direction": { + "Degrees": 256, + "Localized": "WSW", + "English": "WSW" + } + }, + "WindGust": { + "Speed": { + "Value": 11.1, + "Unit": "km/h", + "UnitType": 7 + } + }, + "RelativeHumidity": 91, + "IndoorRelativeHumidity": 61, + "Visibility": { + "Value": 11.3, + "Unit": "km", + "UnitType": 6 + }, + "Ceiling": { + "Value": 9144.0, + "Unit": "m", + "UnitType": 5 + }, + "UVIndex": 0, + "UVIndexFloat": 0.0, + "UVIndexText": "niskie", + "PrecipitationProbability": 4, + "ThunderstormProbability": 0, + "RainProbability": 4, + "SnowProbability": 0, + "IceProbability": 0, + "TotalLiquid": { + "Value": 0.0, + "Unit": "mm", + "UnitType": 3 + }, + "Rain": { + "Value": 0.0, + "Unit": "mm", + "UnitType": 3 + }, + "Snow": { + "Value": 0.0, + "Unit": "cm", + "UnitType": 4 + }, + "Ice": { + "Value": 0.0, + "Unit": "mm", + "UnitType": 3 + }, + "CloudCover": 74, + "Evapotranspiration": { + "Value": 0.0, + "Unit": "mm", + "UnitType": 3 + }, + "SolarIrradiance": { + "Value": 0.0, + "Unit": "W/m\u00b2", + "UnitType": 33 + }, + "AccuLumenBrightnessIndex": 0.0 + }, + { + "DateTime": "2025-09-13t02:00:00+02:00", + "EpochDateTime": 1757721600, + "WeatherIcon": 7, + "IconPhrase": "pochmurno", + "HasPrecipitation": false, + "IsDaylight": false, + "Temperature": { + "Value": 13.9, + "Unit": "C", + "UnitType": 17 + }, + "RealFeelTemperature": { + "Value": 13.5, + "Unit": "C", + "UnitType": 17, + "Phrase": "Ch\u0142odno" + }, + "RealFeelTemperatureShade": { + "Value": 13.5, + "Unit": "C", + "UnitType": 17, + "Phrase": "Ch\u0142odno" + }, + "WetBulbTemperature": { + "Value": 13.1, + "Unit": "C", + "UnitType": 17 + }, + "WetBulbGlobeTemperature": { + "Value": 13.3, + "Unit": "C", + "UnitType": 17 + }, + "DewPoint": { + "Value": 12.3, + "Unit": "C", + "UnitType": 17 + }, + "Wind": { + "Speed": { + "Value": 7.4, + "Unit": "km/h", + "UnitType": 7 + }, + "Direction": { + "Degrees": 244, + "Localized": "WSW", + "English": "WSW" + } + }, + "WindGust": { + "Speed": { + "Value": 11.1, + "Unit": "km/h", + "UnitType": 7 + } + }, + "RelativeHumidity": 90, + "IndoorRelativeHumidity": 61, + "Visibility": { + "Value": 9.7, + "Unit": "km", + "UnitType": 6 + }, + "Ceiling": { + "Value": 9144.0, + "Unit": "m", + "UnitType": 5 + }, + "UVIndex": 0, + "UVIndexFloat": 0.0, + "UVIndexText": "niskie", + "PrecipitationProbability": 5, + "ThunderstormProbability": 0, + "RainProbability": 5, + "SnowProbability": 0, + "IceProbability": 0, + "TotalLiquid": { + "Value": 0.0, + "Unit": "mm", + "UnitType": 3 + }, + "Rain": { + "Value": 0.0, + "Unit": "mm", + "UnitType": 3 + }, + "Snow": { + "Value": 0.0, + "Unit": "cm", + "UnitType": 4 + }, + "Ice": { + "Value": 0.0, + "Unit": "mm", + "UnitType": 3 + }, + "CloudCover": 100, + "Evapotranspiration": { + "Value": 0.0, + "Unit": "mm", + "UnitType": 3 + }, + "SolarIrradiance": { + "Value": 0.0, + "Unit": "W/m\u00b2", + "UnitType": 33 + }, + "AccuLumenBrightnessIndex": 0.0 + }, + { + "DateTime": "2025-09-13t03:00:00+02:00", + "EpochDateTime": 1757725200, + "WeatherIcon": 7, + "IconPhrase": "pochmurno", + "HasPrecipitation": false, + "IsDaylight": false, + "Temperature": { + "Value": 14.0, + "Unit": "C", + "UnitType": 17 + }, + "RealFeelTemperature": { + "Value": 13.6, + "Unit": "C", + "UnitType": 17, + "Phrase": "Ch\u0142odno" + }, + "RealFeelTemperatureShade": { + "Value": 13.6, + "Unit": "C", + "UnitType": 17, + "Phrase": "Ch\u0142odno" + }, + "WetBulbTemperature": { + "Value": 13.0, + "Unit": "C", + "UnitType": 17 + }, + "WetBulbGlobeTemperature": { + "Value": 13.4, + "Unit": "C", + "UnitType": 17 + }, + "DewPoint": { + "Value": 12.2, + "Unit": "C", + "UnitType": 17 + }, + "Wind": { + "Speed": { + "Value": 7.4, + "Unit": "km/h", + "UnitType": 7 + }, + "Direction": { + "Degrees": 229, + "Localized": "SW", + "English": "SW" + } + }, + "WindGust": { + "Speed": { + "Value": 9.3, + "Unit": "km/h", + "UnitType": 7 + } + }, + "RelativeHumidity": 89, + "IndoorRelativeHumidity": 60, + "Visibility": { + "Value": 9.7, + "Unit": "km", + "UnitType": 6 + }, + "Ceiling": { + "Value": 7376.0, + "Unit": "m", + "UnitType": 5 + }, + "UVIndex": 0, + "UVIndexFloat": 0.0, + "UVIndexText": "niskie", + "PrecipitationProbability": 5, + "ThunderstormProbability": 0, + "RainProbability": 5, + "SnowProbability": 0, + "IceProbability": 0, + "TotalLiquid": { + "Value": 0.0, + "Unit": "mm", + "UnitType": 3 + }, + "Rain": { + "Value": 0.0, + "Unit": "mm", + "UnitType": 3 + }, + "Snow": { + "Value": 0.0, + "Unit": "cm", + "UnitType": 4 + }, + "Ice": { + "Value": 0.0, + "Unit": "mm", + "UnitType": 3 + }, + "CloudCover": 98, + "Evapotranspiration": { + "Value": 0.0, + "Unit": "mm", + "UnitType": 3 + }, + "SolarIrradiance": { + "Value": 0.0, + "Unit": "W/m\u00b2", + "UnitType": 33 + }, + "AccuLumenBrightnessIndex": 0.0 + } +] diff --git a/tests/components/accuweather/snapshots/test_weather.ambr b/tests/components/accuweather/snapshots/test_weather.ambr index 254667d7809d89..ae17c76511c3b3 100644 --- a/tests/components/accuweather/snapshots/test_weather.ambr +++ b/tests/components/accuweather/snapshots/test_weather.ambr @@ -1,5 +1,5 @@ # serializer version: 1 -# name: test_forecast_service[get_forecasts] +# name: test_forecast_service[daily] dict({ 'weather.home': dict({ 'forecast': list([ @@ -82,6 +82,182 @@ }), }) # --- +# name: test_forecast_service[hourly] + dict({ + 'weather.home': dict({ + 'forecast': list([ + dict({ + 'apparent_temperature': 22.6, + 'cloud_coverage': 13, + 'condition': 'sunny', + 'datetime': '2025-09-12T14:00:00+00:00', + 'humidity': 48, + 'precipitation': 0.0, + 'precipitation_probability': 1, + 'temperature': 22.5, + 'uv_index': 2, + 'wind_bearing': 239, + 'wind_gust_speed': 24.1, + 'wind_speed': 14.8, + }), + dict({ + 'apparent_temperature': 22.9, + 'cloud_coverage': 17, + 'condition': 'sunny', + 'datetime': '2025-09-12T15:00:00+00:00', + 'humidity': 48, + 'precipitation': 0.0, + 'precipitation_probability': 1, + 'temperature': 23.1, + 'uv_index': 2, + 'wind_bearing': 238, + 'wind_gust_speed': 22.2, + 'wind_speed': 13.0, + }), + dict({ + 'apparent_temperature': 20.6, + 'cloud_coverage': 23, + 'condition': 'sunny', + 'datetime': '2025-09-12T16:00:00+00:00', + 'humidity': 56, + 'precipitation': 0.0, + 'precipitation_probability': 1, + 'temperature': 21.3, + 'uv_index': 1, + 'wind_bearing': 232, + 'wind_gust_speed': 18.5, + 'wind_speed': 13.0, + }), + dict({ + 'apparent_temperature': 18.2, + 'cloud_coverage': 29, + 'condition': 'sunny', + 'datetime': '2025-09-12T17:00:00+00:00', + 'humidity': 62, + 'precipitation': 0.0, + 'precipitation_probability': 2, + 'temperature': 19.5, + 'uv_index': 0, + 'wind_bearing': 224, + 'wind_gust_speed': 16.7, + 'wind_speed': 13.0, + }), + dict({ + 'apparent_temperature': 16.7, + 'cloud_coverage': 34, + 'condition': 'partlycloudy', + 'datetime': '2025-09-12T18:00:00+00:00', + 'humidity': 69, + 'precipitation': 0.0, + 'precipitation_probability': 3, + 'temperature': 17.7, + 'uv_index': 0, + 'wind_bearing': 219, + 'wind_gust_speed': 14.8, + 'wind_speed': 11.1, + }), + dict({ + 'apparent_temperature': 14.9, + 'cloud_coverage': 30, + 'condition': 'partlycloudy', + 'datetime': '2025-09-12T19:00:00+00:00', + 'humidity': 77, + 'precipitation': 0.0, + 'precipitation_probability': 3, + 'temperature': 15.8, + 'uv_index': 0, + 'wind_bearing': 230, + 'wind_gust_speed': 13.0, + 'wind_speed': 11.1, + }), + dict({ + 'apparent_temperature': 13.8, + 'cloud_coverage': 26, + 'condition': 'clear-night', + 'datetime': '2025-09-12T20:00:00+00:00', + 'humidity': 84, + 'precipitation': 0.0, + 'precipitation_probability': 3, + 'temperature': 14.6, + 'uv_index': 0, + 'wind_bearing': 259, + 'wind_gust_speed': 13.0, + 'wind_speed': 9.3, + }), + dict({ + 'apparent_temperature': 13.8, + 'cloud_coverage': 22, + 'condition': 'clear-night', + 'datetime': '2025-09-12T21:00:00+00:00', + 'humidity': 86, + 'precipitation': 0.0, + 'precipitation_probability': 4, + 'temperature': 14.4, + 'uv_index': 0, + 'wind_bearing': 272, + 'wind_gust_speed': 13.0, + 'wind_speed': 9.3, + }), + dict({ + 'apparent_temperature': 13.5, + 'cloud_coverage': 48, + 'condition': 'partlycloudy', + 'datetime': '2025-09-12T22:00:00+00:00', + 'humidity': 89, + 'precipitation': 0.0, + 'precipitation_probability': 4, + 'temperature': 13.9, + 'uv_index': 0, + 'wind_bearing': 265, + 'wind_gust_speed': 13.0, + 'wind_speed': 7.4, + }), + dict({ + 'apparent_temperature': 13.2, + 'cloud_coverage': 74, + 'condition': 'partlycloudy', + 'datetime': '2025-09-12T23:00:00+00:00', + 'humidity': 91, + 'precipitation': 0.0, + 'precipitation_probability': 4, + 'temperature': 13.6, + 'uv_index': 0, + 'wind_bearing': 256, + 'wind_gust_speed': 11.1, + 'wind_speed': 7.4, + }), + dict({ + 'apparent_temperature': 13.5, + 'cloud_coverage': 100, + 'condition': 'cloudy', + 'datetime': '2025-09-13T00:00:00+00:00', + 'humidity': 90, + 'precipitation': 0.0, + 'precipitation_probability': 5, + 'temperature': 13.9, + 'uv_index': 0, + 'wind_bearing': 244, + 'wind_gust_speed': 11.1, + 'wind_speed': 7.4, + }), + dict({ + 'apparent_temperature': 13.6, + 'cloud_coverage': 98, + 'condition': 'cloudy', + 'datetime': '2025-09-13T01:00:00+00:00', + 'humidity': 89, + 'precipitation': 0.0, + 'precipitation_probability': 5, + 'temperature': 14.0, + 'uv_index': 0, + 'wind_bearing': 229, + 'wind_gust_speed': 9.3, + 'wind_speed': 7.4, + }), + ]), + }), + }) +# --- # name: test_forecast_subscription list([ dict({ @@ -269,7 +445,7 @@ 'platform': 'accuweather', 'previous_unique_id': None, 'suggested_object_id': None, - 'supported_features': , + 'supported_features': , 'translation_key': None, 'unique_id': '0123456', 'unit_of_measurement': None, @@ -287,7 +463,7 @@ 'precipitation_unit': , 'pressure': 1012.0, 'pressure_unit': , - 'supported_features': , + 'supported_features': , 'temperature': 22.6, 'temperature_unit': , 'uv_index': 6, diff --git a/tests/components/accuweather/test_weather.py b/tests/components/accuweather/test_weather.py index a23b09fec29b8a..7e163e40d83273 100644 --- a/tests/components/accuweather/test_weather.py +++ b/tests/components/accuweather/test_weather.py @@ -107,24 +107,24 @@ async def test_unsupported_condition_icon_data( @pytest.mark.parametrize( - ("service"), - [SERVICE_GET_FORECASTS], + ("forecast_type"), + ["daily", "hourly"], ) async def test_forecast_service( hass: HomeAssistant, snapshot: SnapshotAssertion, mock_accuweather_client: AsyncMock, - service: str, + forecast_type: str, ) -> None: """Test multiple forecast.""" await init_integration(hass) response = await hass.services.async_call( WEATHER_DOMAIN, - service, + SERVICE_GET_FORECASTS, { "entity_id": "weather.home", - "type": "daily", + "type": forecast_type, }, blocking=True, return_response=True, From 91e7a35a0718672d9213ae33b52b45da6cdc8c72 Mon Sep 17 00:00:00 2001 From: Nathan Spencer Date: Fri, 12 Sep 2025 12:36:58 -0400 Subject: [PATCH 10/30] Add gravity mode switch for Feeder-Robot (#152175) --- .../components/litterrobot/strings.json | 3 ++ .../components/litterrobot/switch.py | 41 ++++++++++++------- tests/components/litterrobot/conftest.py | 1 + tests/components/litterrobot/test_switch.py | 26 +++++++++++- 4 files changed, 55 insertions(+), 16 deletions(-) diff --git a/homeassistant/components/litterrobot/strings.json b/homeassistant/components/litterrobot/strings.json index e68e74011bdc73..b0facf155d65d8 100644 --- a/homeassistant/components/litterrobot/strings.json +++ b/homeassistant/components/litterrobot/strings.json @@ -157,6 +157,9 @@ } }, "switch": { + "gravity_mode": { + "name": "Gravity mode" + }, "night_light_mode": { "name": "Night light mode" }, diff --git a/homeassistant/components/litterrobot/switch.py b/homeassistant/components/litterrobot/switch.py index 5924f8f094aea8..310859d98a2dde 100644 --- a/homeassistant/components/litterrobot/switch.py +++ b/homeassistant/components/litterrobot/switch.py @@ -6,7 +6,7 @@ from dataclasses import dataclass from typing import Any, Generic -from pylitterbot import FeederRobot, LitterRobot +from pylitterbot import FeederRobot, LitterRobot, Robot from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription from homeassistant.const import EntityCategory @@ -26,20 +26,30 @@ class RobotSwitchEntityDescription(SwitchEntityDescription, Generic[_WhiskerEnti value_fn: Callable[[_WhiskerEntityT], bool] -ROBOT_SWITCHES = [ - RobotSwitchEntityDescription[LitterRobot | FeederRobot]( - key="night_light_mode_enabled", - translation_key="night_light_mode", - set_fn=lambda robot, value: robot.set_night_light(value), - value_fn=lambda robot: robot.night_light_mode_enabled, +SWITCH_MAP: dict[type[Robot], tuple[RobotSwitchEntityDescription, ...]] = { + FeederRobot: ( + RobotSwitchEntityDescription[FeederRobot]( + key="gravity_mode", + translation_key="gravity_mode", + set_fn=lambda robot, value: robot.set_gravity_mode(value), + value_fn=lambda robot: robot.gravity_mode_enabled, + ), ), - RobotSwitchEntityDescription[LitterRobot | FeederRobot]( - key="panel_lock_enabled", - translation_key="panel_lockout", - set_fn=lambda robot, value: robot.set_panel_lockout(value), - value_fn=lambda robot: robot.panel_lock_enabled, + Robot: ( # type: ignore[type-abstract] # only used for isinstance check + RobotSwitchEntityDescription[LitterRobot | FeederRobot]( + key="night_light_mode_enabled", + translation_key="night_light_mode", + set_fn=lambda robot, value: robot.set_night_light(value), + value_fn=lambda robot: robot.night_light_mode_enabled, + ), + RobotSwitchEntityDescription[LitterRobot | FeederRobot]( + key="panel_lock_enabled", + translation_key="panel_lockout", + set_fn=lambda robot, value: robot.set_panel_lockout(value), + value_fn=lambda robot: robot.panel_lock_enabled, + ), ), -] +} async def async_setup_entry( @@ -51,9 +61,10 @@ async def async_setup_entry( coordinator = entry.runtime_data async_add_entities( RobotSwitchEntity(robot=robot, coordinator=coordinator, description=description) - for description in ROBOT_SWITCHES for robot in coordinator.account.robots - if isinstance(robot, (LitterRobot, FeederRobot)) + for robot_type, entity_descriptions in SWITCH_MAP.items() + if isinstance(robot, robot_type) + for description in entity_descriptions ) diff --git a/tests/components/litterrobot/conftest.py b/tests/components/litterrobot/conftest.py index aa67db23d89669..5075b5d5efd7ee 100644 --- a/tests/components/litterrobot/conftest.py +++ b/tests/components/litterrobot/conftest.py @@ -39,6 +39,7 @@ def create_mock_robot( robot = LitterRobot4(data={**ROBOT_4_DATA, **robot_data}, account=account) elif feeder: robot = FeederRobot(data={**FEEDER_ROBOT_DATA, **robot_data}, account=account) + robot.set_gravity_mode = AsyncMock(side_effect=side_effect) else: robot = LitterRobot3(data={**ROBOT_DATA, **robot_data}, account=account) robot.start_cleaning = AsyncMock(side_effect=side_effect) diff --git a/tests/components/litterrobot/test_switch.py b/tests/components/litterrobot/test_switch.py index d81c02bee49a80..a1ccddc79d177f 100644 --- a/tests/components/litterrobot/test_switch.py +++ b/tests/components/litterrobot/test_switch.py @@ -2,7 +2,7 @@ from unittest.mock import MagicMock -from pylitterbot import Robot +from pylitterbot import FeederRobot, Robot import pytest from homeassistant.components.switch import ( @@ -66,3 +66,27 @@ async def test_on_off_commands( assert getattr(robot, robot_command).call_count == count + 1 assert (state := hass.states.get(entity_id)) assert state.state == new_state + + +async def test_feeder_robot_switch( + hass: HomeAssistant, mock_account_with_feederrobot: MagicMock +) -> None: + """Tests Feeder-Robot switches.""" + await setup_integration(hass, mock_account_with_feederrobot, PLATFORM_DOMAIN) + robot: FeederRobot = mock_account_with_feederrobot.robots[0] + + gravity_mode_switch = "switch.test_gravity_mode" + + switch = hass.states.get(gravity_mode_switch) + assert switch.state == STATE_OFF + + data = {ATTR_ENTITY_ID: gravity_mode_switch} + + services = ((SERVICE_TURN_ON, STATE_ON, True), (SERVICE_TURN_OFF, STATE_OFF, False)) + for count, (service, new_state, new_value) in enumerate(services): + await hass.services.async_call(PLATFORM_DOMAIN, service, data, blocking=True) + robot._update_data({"state": {"info": {"gravity": new_value}}}, partial=True) + + assert robot.set_gravity_mode.call_count == count + 1 + assert (state := hass.states.get(gravity_mode_switch)) + assert state.state == new_state From fd1df5ad881922b0cefd94b343e4efaecb450d1d Mon Sep 17 00:00:00 2001 From: Marcos Alano Date: Fri, 12 Sep 2025 13:39:05 -0300 Subject: [PATCH 11/30] Add select for up/down/stop to electric desk (#152166) --- homeassistant/components/tuya/const.py | 1 + homeassistant/components/tuya/select.py | 5 ++ homeassistant/components/tuya/strings.json | 8 +++ .../tuya/snapshots/test_select.ambr | 59 +++++++++++++++++++ 4 files changed, 73 insertions(+) diff --git a/homeassistant/components/tuya/const.py b/homeassistant/components/tuya/const.py index 77dd8a2fefd9c2..a1ad046692d819 100644 --- a/homeassistant/components/tuya/const.py +++ b/homeassistant/components/tuya/const.py @@ -424,6 +424,7 @@ class DPCode(StrEnum): TOTAL_POWER = "total_power" TOTAL_TIME = "total_time" TVOC = "tvoc" + UP_DOWN = "up_down" UPPER_TEMP = "upper_temp" UPPER_TEMP_F = "upper_temp_f" UV = "uv" # UV sterilization diff --git a/homeassistant/components/tuya/select.py b/homeassistant/components/tuya/select.py index 8b62ed36a52f7c..0d62620b88e525 100644 --- a/homeassistant/components/tuya/select.py +++ b/homeassistant/components/tuya/select.py @@ -254,6 +254,11 @@ translation_key="desk_level", entity_category=EntityCategory.CONFIG, ), + SelectEntityDescription( + key=DPCode.UP_DOWN, + translation_key="desk_up_down", + entity_category=EntityCategory.CONFIG, + ), ), # Smart Camera # https://developer.tuya.com/en/docs/iot/categorysp?id=Kaiuz35leyo12 diff --git a/homeassistant/components/tuya/strings.json b/homeassistant/components/tuya/strings.json index d5d9bdaeeed15d..d470492e9d7525 100644 --- a/homeassistant/components/tuya/strings.json +++ b/homeassistant/components/tuya/strings.json @@ -534,6 +534,14 @@ "level_4": "Level 4" } }, + "desk_up_down": { + "name": "Up/Down", + "state": { + "up": "Up", + "down": "Down", + "stop": "Stop" + } + }, "inverter_work_mode": { "name": "Inverter work mode", "state": { diff --git a/tests/components/tuya/snapshots/test_select.ambr b/tests/components/tuya/snapshots/test_select.ambr index 1a5061f3b1a0fb..ce90522885d65e 100644 --- a/tests/components/tuya/snapshots/test_select.ambr +++ b/tests/components/tuya/snapshots/test_select.ambr @@ -3197,6 +3197,65 @@ 'state': 'level_1', }) # --- +# name: test_platform_setup_and_discovery[select.mesa_up_down-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'up', + 'down', + 'stop', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': , + 'entity_id': 'select.mesa_up_down', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Up/Down', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'desk_up_down', + 'unique_id': 'tuya.vpfdskpi8pr8cbtfzjsup_down', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[select.mesa_up_down-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'mesa Up/Down', + 'options': list([ + 'up', + 'down', + 'stop', + ]), + }), + 'context': , + 'entity_id': 'select.mesa_up_down', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'stop', + }) +# --- # name: test_platform_setup_and_discovery[select.mirilla_puerta_anti_flicker-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ From 521ff62aae0564e8e2403b45c5f762366590076e Mon Sep 17 00:00:00 2001 From: Luke Lashley Date: Fri, 12 Sep 2025 12:50:42 -0400 Subject: [PATCH 12/30] Make Roborock map transparent by default (#152092) --- .../components/roborock/config_flow.py | 8 +++++++ homeassistant/components/roborock/const.py | 1 + .../components/roborock/coordinator.py | 8 +++++-- .../components/roborock/strings.json | 6 +++-- tests/components/roborock/test_coordinator.py | 23 +++++++++++++++++++ 5 files changed, 42 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/roborock/config_flow.py b/homeassistant/components/roborock/config_flow.py index 6a35bf79233924..e5f449d4984768 100644 --- a/homeassistant/components/roborock/config_flow.py +++ b/homeassistant/components/roborock/config_flow.py @@ -35,6 +35,7 @@ from .const import ( CONF_BASE_URL, CONF_ENTRY_CODE, + CONF_SHOW_BACKGROUND, CONF_USER_DATA, DEFAULT_DRAWABLES, DOMAIN, @@ -215,6 +216,7 @@ async def async_step_drawables( ) -> ConfigFlowResult: """Manage the map object drawable options.""" if user_input is not None: + self.options[CONF_SHOW_BACKGROUND] = user_input.pop(CONF_SHOW_BACKGROUND) self.options.setdefault(DRAWABLES, {}).update(user_input) return self.async_create_entry(title="", data=self.options) data_schema = {} @@ -227,6 +229,12 @@ async def async_step_drawables( ), ) ] = bool + data_schema[ + vol.Required( + CONF_SHOW_BACKGROUND, + default=self.config_entry.options.get(CONF_SHOW_BACKGROUND, False), + ) + ] = bool return self.async_show_form( step_id=DRAWABLES, data_schema=vol.Schema(data_schema), diff --git a/homeassistant/components/roborock/const.py b/homeassistant/components/roborock/const.py index e56fade7078ea2..3ddce364e9f31b 100644 --- a/homeassistant/components/roborock/const.py +++ b/homeassistant/components/roborock/const.py @@ -10,6 +10,7 @@ CONF_ENTRY_CODE = "code" CONF_BASE_URL = "base_url" CONF_USER_DATA = "user_data" +CONF_SHOW_BACKGROUND = "show_background" # Option Flow steps DRAWABLES = "drawables" diff --git a/homeassistant/components/roborock/coordinator.py b/homeassistant/components/roborock/coordinator.py index dc0677b25d2ff3..02d5f68466808c 100644 --- a/homeassistant/components/roborock/coordinator.py +++ b/homeassistant/components/roborock/coordinator.py @@ -26,7 +26,7 @@ from roborock.version_1_apis.roborock_mqtt_client_v1 import RoborockMqttClientV1 from roborock.version_a01_apis import RoborockClientA01 from roborock.web_api import RoborockApiClient -from vacuum_map_parser_base.config.color import ColorsPalette +from vacuum_map_parser_base.config.color import ColorsPalette, SupportedColor from vacuum_map_parser_base.config.image_config import ImageConfig from vacuum_map_parser_base.config.size import Size, Sizes from vacuum_map_parser_base.map_data import MapData @@ -44,6 +44,7 @@ from .const import ( A01_UPDATE_INTERVAL, + CONF_SHOW_BACKGROUND, DEFAULT_DRAWABLES, DOMAIN, DRAWABLES, @@ -146,8 +147,11 @@ def __init__( for drawable, default_value in DEFAULT_DRAWABLES.items() if config_entry.options.get(DRAWABLES, {}).get(drawable, default_value) ] + colors = ColorsPalette() + if not config_entry.options.get(CONF_SHOW_BACKGROUND, False): + colors = ColorsPalette({SupportedColor.MAP_OUTSIDE: (0, 0, 0, 0)}) self.map_parser = RoborockMapDataParser( - ColorsPalette(), + colors, Sizes( { k: v * MAP_SCALE diff --git a/homeassistant/components/roborock/strings.json b/homeassistant/components/roborock/strings.json index 2d1fcebd9d3706..0eff2287a73e1b 100644 --- a/homeassistant/components/roborock/strings.json +++ b/homeassistant/components/roborock/strings.json @@ -60,7 +60,8 @@ "room_names": "Room names", "vacuum_position": "Vacuum position", "virtual_walls": "Virtual walls", - "zones": "Zones" + "zones": "Zones", + "show_background": "Show background" }, "data_description": { "charger": "Show the charger on the map.", @@ -79,7 +80,8 @@ "room_names": "Show room names on the map.", "vacuum_position": "Show the vacuum position on the map.", "virtual_walls": "Show virtual walls on the map.", - "zones": "Show zones on the map." + "zones": "Show zones on the map.", + "show_background": "Add a background to the map." } } } diff --git a/tests/components/roborock/test_coordinator.py b/tests/components/roborock/test_coordinator.py index dec4e0a62d40fe..22efddf5817fb1 100644 --- a/tests/components/roborock/test_coordinator.py +++ b/tests/components/roborock/test_coordinator.py @@ -6,13 +6,16 @@ import pytest from roborock.exceptions import RoborockException +from vacuum_map_parser_base.config.color import SupportedColor from homeassistant.components.roborock.const import ( + CONF_SHOW_BACKGROUND, V1_CLOUD_IN_CLEANING_INTERVAL, V1_CLOUD_NOT_CLEANING_INTERVAL, V1_LOCAL_IN_CLEANING_INTERVAL, V1_LOCAL_NOT_CLEANING_INTERVAL, ) +from homeassistant.components.roborock.coordinator import RoborockDataUpdateCoordinator from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.util import dt as dt_util @@ -73,6 +76,26 @@ async def test_dynamic_cloud_scan_interval( assert hass.states.get("sensor.roborock_s7_maxv_battery").state == "20" +async def test_visible_background( + hass: HomeAssistant, + mock_roborock_entry: MockConfigEntry, + bypass_api_fixture: None, +) -> None: + """Test that a visible background is handled correctly.""" + hass.config_entries.async_update_entry( + mock_roborock_entry, + options={ + CONF_SHOW_BACKGROUND: True, + }, + ) + await hass.config_entries.async_setup(mock_roborock_entry.entry_id) + await hass.async_block_till_done() + coordinator: RoborockDataUpdateCoordinator = mock_roborock_entry.runtime_data.v1[0] + assert coordinator.map_parser._palette.get_color( # pylint: disable=protected-access + SupportedColor.MAP_OUTSIDE + ) != (0, 0, 0, 0) + + @pytest.mark.parametrize( ("interval", "in_cleaning"), [ From 0cebca498c338ad07c3df83f1a32528c82dcadd0 Mon Sep 17 00:00:00 2001 From: "Thijs W." Date: Fri, 12 Sep 2025 18:55:22 +0200 Subject: [PATCH 13/30] Bump pymodbus to 3.11.2 (#152097) Co-authored-by: Franck Nijhof Co-authored-by: Franck Nijhof --- homeassistant/components/modbus/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- script/gen_requirements_all.py | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/modbus/manifest.json b/homeassistant/components/modbus/manifest.json index 0bcaf67cd13235..429633224239e6 100644 --- a/homeassistant/components/modbus/manifest.json +++ b/homeassistant/components/modbus/manifest.json @@ -5,5 +5,5 @@ "documentation": "https://www.home-assistant.io/integrations/modbus", "iot_class": "local_polling", "loggers": ["pymodbus"], - "requirements": ["pymodbus==3.11.1"] + "requirements": ["pymodbus==3.11.2"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index c1d7b581e3fc50..98622eab1d2086 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -219,7 +219,7 @@ num2words==0.5.14 # pymodbus does not follow SemVer, and it keeps getting # downgraded or upgraded by custom components # This ensures all use the same version -pymodbus==3.11.1 +pymodbus==3.11.2 # Some packages don't support gql 4.0.0 yet gql<4.0.0 diff --git a/requirements_all.txt b/requirements_all.txt index 1e3877ae0b3007..cd29b2345e51fd 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2163,7 +2163,7 @@ pymitv==1.4.3 pymochad==0.2.0 # homeassistant.components.modbus -pymodbus==3.11.1 +pymodbus==3.11.2 # homeassistant.components.monoprice pymonoprice==0.4 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 12a50fd8b026f4..2778ab8af60dd1 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1802,7 +1802,7 @@ pymiele==0.5.4 pymochad==0.2.0 # homeassistant.components.modbus -pymodbus==3.11.1 +pymodbus==3.11.2 # homeassistant.components.monoprice pymonoprice==0.4 diff --git a/script/gen_requirements_all.py b/script/gen_requirements_all.py index 8a6c09ff3a4c05..e482c01b3dd602 100755 --- a/script/gen_requirements_all.py +++ b/script/gen_requirements_all.py @@ -245,7 +245,7 @@ # pymodbus does not follow SemVer, and it keeps getting # downgraded or upgraded by custom components # This ensures all use the same version -pymodbus==3.11.1 +pymodbus==3.11.2 # Some packages don't support gql 4.0.0 yet gql<4.0.0 From 9fae4e7e1f742efc666aa8d0dc65d59b2751d34a Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Fri, 12 Sep 2025 19:00:54 +0200 Subject: [PATCH 14/30] Add support for Tuya bzyd category (white noise machine) (#152025) --- homeassistant/components/tuya/const.py | 2 + homeassistant/components/tuya/entity.py | 2 +- homeassistant/components/tuya/light.py | 11 +- homeassistant/components/tuya/number.py | 8 + homeassistant/components/tuya/strings.json | 6 + homeassistant/components/tuya/switch.py | 25 +++ tests/components/tuya/conftest.py | 10 +- .../components/tuya/snapshots/test_init.ambr | 4 +- .../components/tuya/snapshots/test_light.ambr | 132 ++++++++++++++++ .../tuya/snapshots/test_number.ambr | 116 ++++++++++++++ .../tuya/snapshots/test_switch.ambr | 146 ++++++++++++++++++ 11 files changed, 453 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/tuya/const.py b/homeassistant/components/tuya/const.py index a1ad046692d819..19c7ffac7dd365 100644 --- a/homeassistant/components/tuya/const.py +++ b/homeassistant/components/tuya/const.py @@ -331,6 +331,7 @@ class DPCode(StrEnum): SMOKE_SENSOR_STATE = "smoke_sensor_state" SMOKE_SENSOR_STATUS = "smoke_sensor_status" SMOKE_SENSOR_VALUE = "smoke_sensor_value" + SNOOZE = "snooze" SOS = "sos" # Emergency State SOS_STATE = "sos_state" # Emergency mode SPEED = "speed" # Speed level @@ -371,6 +372,7 @@ class DPCode(StrEnum): SWITCH_MODE7 = "switch_mode7" SWITCH_MODE8 = "switch_mode8" SWITCH_MODE9 = "switch_mode9" + SWITCH_MUSIC = "switch_music" SWITCH_NIGHT_LIGHT = "switch_night_light" SWITCH_SAVE_ENERGY = "switch_save_energy" SWITCH_SOUND = "switch_sound" # Voice switch diff --git a/homeassistant/components/tuya/entity.py b/homeassistant/components/tuya/entity.py index 7d51a006877b49..1ed9aae1f22195 100644 --- a/homeassistant/components/tuya/entity.py +++ b/homeassistant/components/tuya/entity.py @@ -126,7 +126,7 @@ def find_dpcode( return None def get_dptype( - self, dpcode: DPCode | None, prefer_function: bool = False + self, dpcode: DPCode | None, *, prefer_function: bool = False ) -> DPType | None: """Find a matching DPCode data type available on for this device.""" if dpcode is None: diff --git a/homeassistant/components/tuya/light.py b/homeassistant/components/tuya/light.py index 673e9b1ffb3009..9dba24ec490b8f 100644 --- a/homeassistant/components/tuya/light.py +++ b/homeassistant/components/tuya/light.py @@ -73,6 +73,15 @@ class TuyaLightEntityDescription(LightEntityDescription): LIGHTS: dict[str, tuple[TuyaLightEntityDescription, ...]] = { + # White noise machine + "bzyd": ( + TuyaLightEntityDescription( + key=DPCode.SWITCH_LED, + name=None, + color_mode=DPCode.WORK_MODE, + color_data=DPCode.COLOUR_DATA, + ), + ), # Curtain Switch # https://developer.tuya.com/en/docs/iot/category-clkg?id=Kaiuz0gitil39 "clkg": ( @@ -531,7 +540,7 @@ def __init__( if ( dpcode := get_dpcode(self.device, description.color_data) - ) and self.get_dptype(dpcode) == DPType.JSON: + ) and self.get_dptype(dpcode, prefer_function=True) == DPType.JSON: self._color_data_dpcode = dpcode color_modes.add(ColorMode.HS) if dpcode in self.device.function: diff --git a/homeassistant/components/tuya/number.py b/homeassistant/components/tuya/number.py index 3ee6900d228ad2..6a4482821badaf 100644 --- a/homeassistant/components/tuya/number.py +++ b/homeassistant/components/tuya/number.py @@ -65,6 +65,14 @@ entity_category=EntityCategory.CONFIG, ), ), + # White noise machine + "bzyd": ( + NumberEntityDescription( + key=DPCode.VOLUME_SET, + translation_key="volume", + entity_category=EntityCategory.CONFIG, + ), + ), # CO2 Detector # https://developer.tuya.com/en/docs/iot/categoryco2bj?id=Kaiuz3wes7yuy "co2bj": ( diff --git a/homeassistant/components/tuya/strings.json b/homeassistant/components/tuya/strings.json index d470492e9d7525..7781fc926ca047 100644 --- a/homeassistant/components/tuya/strings.json +++ b/homeassistant/components/tuya/strings.json @@ -981,6 +981,12 @@ }, "output_power_limit": { "name": "Output power limit" + }, + "music": { + "name": "Music" + }, + "snooze": { + "name": "Snooze" } }, "valve": { diff --git a/homeassistant/components/tuya/switch.py b/homeassistant/components/tuya/switch.py index 62ea4d86b3d0c4..208cd3e19b7b32 100644 --- a/homeassistant/components/tuya/switch.py +++ b/homeassistant/components/tuya/switch.py @@ -37,6 +37,31 @@ entity_category=EntityCategory.CONFIG, ), ), + # White noise machine + "bzyd": ( + SwitchEntityDescription( + key=DPCode.SWITCH, + name=None, + ), + SwitchEntityDescription( + key=DPCode.CHILD_LOCK, + translation_key="child_lock", + icon="mdi:account-lock", + entity_category=EntityCategory.CONFIG, + ), + SwitchEntityDescription( + key=DPCode.SWITCH_MUSIC, + translation_key="music", + icon="mdi:music", + entity_category=EntityCategory.CONFIG, + ), + SwitchEntityDescription( + key=DPCode.SNOOZE, + translation_key="snooze", + icon="mdi:alarm-snooze", + entity_category=EntityCategory.CONFIG, + ), + ), # Curtain # https://developer.tuya.com/en/docs/iot/f?id=K9gf46o5mtfyc "cl": ( diff --git a/tests/components/tuya/conftest.py b/tests/components/tuya/conftest.py index a699eb7846ce80..21e558b7192d1c 100644 --- a/tests/components/tuya/conftest.py +++ b/tests/components/tuya/conftest.py @@ -208,11 +208,11 @@ async def _create_device(hass: HomeAssistant, mock_device_code: str) -> Customer } device.status = details["status"] for key, value in device.status.items(): - # Some devices to not provide a status_range for all status DPs - dp_type = device.status_range.get(key) - if dp_type is None: - dp_type = device.function[key] - if dp_type.type == "Json": + # Some devices do not provide a status_range for all status DPs + # Others set the type as String in status_range and as Json in function + if ((dp_type := device.status_range.get(key)) and dp_type.type == "Json") or ( + (dp_type := device.function.get(key)) and dp_type.type == "Json" + ): device.status[key] = json_dumps(value) return device diff --git a/tests/components/tuya/snapshots/test_init.ambr b/tests/components/tuya/snapshots/test_init.ambr index a3b0b0b10c8d0f..533aee7d687258 100644 --- a/tests/components/tuya/snapshots/test_init.ambr +++ b/tests/components/tuya/snapshots/test_init.ambr @@ -2004,7 +2004,7 @@ 'labels': set({ }), 'manufacturer': 'Tuya', - 'model': 'BlissRadia (unsupported)', + 'model': 'BlissRadia ', 'model_id': 'ssimhf6r8kgwepfb', 'name': 'BlissRadia ', 'name_by_user': None, @@ -5755,7 +5755,7 @@ 'labels': set({ }), 'manufacturer': 'Tuya', - 'model': 'Smart White Noise Machine (unsupported)', + 'model': 'Smart White Noise Machine', 'model_id': '45idzfufidgee7ir', 'name': 'Smart White Noise Machine', 'name_by_user': None, diff --git a/tests/components/tuya/snapshots/test_light.ambr b/tests/components/tuya/snapshots/test_light.ambr index 54c4b8784d6e86..c8d7556fa11c54 100644 --- a/tests/components/tuya/snapshots/test_light.ambr +++ b/tests/components/tuya/snapshots/test_light.ambr @@ -345,6 +345,67 @@ 'state': 'off', }) # --- +# name: test_platform_setup_and_discovery[light.blissradia-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'supported_color_modes': list([ + , + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'light', + 'entity_category': None, + 'entity_id': 'light.blissradia', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'tuya.bfpewgk8r6fhmissdyzbswitch_led', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[light.blissradia-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'brightness': None, + 'color_mode': None, + 'friendly_name': 'BlissRadia ', + 'hs_color': None, + 'rgb_color': None, + 'supported_color_modes': list([ + , + ]), + 'supported_features': , + 'xy_color': None, + }), + 'context': , + 'entity_id': 'light.blissradia', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- # name: test_platform_setup_and_discovery[light.cam_garage_indicator_light-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -2957,6 +3018,77 @@ 'state': 'off', }) # --- +# name: test_platform_setup_and_discovery[light.smart_white_noise_machine-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'supported_color_modes': list([ + , + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'light', + 'entity_category': None, + 'entity_id': 'light.smart_white_noise_machine', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'tuya.ri7eegdifufzdi54dyzbswitch_led', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[light.smart_white_noise_machine-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'brightness': 1003, + 'color_mode': , + 'friendly_name': 'Smart White Noise Machine', + 'hs_color': tuple( + 239.666, + 393.307, + ), + 'rgb_color': tuple( + -748, + -742, + 255, + ), + 'supported_color_modes': list([ + , + ]), + 'supported_features': , + 'xy_color': tuple( + -0.03, + -0.215, + ), + }), + 'context': , + 'entity_id': 'light.smart_white_noise_machine', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- # name: test_platform_setup_and_discovery[light.solar_zijpad-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/tuya/snapshots/test_number.ambr b/tests/components/tuya/snapshots/test_number.ambr index 73dab1877e1d43..15003c65db03d7 100644 --- a/tests/components/tuya/snapshots/test_number.ambr +++ b/tests/components/tuya/snapshots/test_number.ambr @@ -58,6 +58,64 @@ 'state': '1.0', }) # --- +# name: test_platform_setup_and_discovery[number.blissradia_volume-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 100.0, + 'min': 5.0, + 'mode': , + 'step': 1.0, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.blissradia_volume', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Volume', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'volume', + 'unique_id': 'tuya.bfpewgk8r6fhmissdyzbvolume_set', + 'unit_of_measurement': '', + }) +# --- +# name: test_platform_setup_and_discovery[number.blissradia_volume-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'BlissRadia Volume', + 'max': 100.0, + 'min': 5.0, + 'mode': , + 'step': 1.0, + 'unit_of_measurement': '', + }), + 'context': , + 'entity_id': 'number.blissradia_volume', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '5.0', + }) +# --- # name: test_platform_setup_and_discovery[number.boiler_temperature_controller_temperature_correction-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -2223,6 +2281,64 @@ 'state': '-2.0', }) # --- +# name: test_platform_setup_and_discovery[number.smart_white_noise_machine_volume-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 100.0, + 'min': 0.0, + 'mode': , + 'step': 1.0, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.smart_white_noise_machine_volume', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Volume', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'volume', + 'unique_id': 'tuya.ri7eegdifufzdi54dyzbvolume_set', + 'unit_of_measurement': '', + }) +# --- +# name: test_platform_setup_and_discovery[number.smart_white_noise_machine_volume-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Smart White Noise Machine Volume', + 'max': 100.0, + 'min': 0.0, + 'mode': , + 'step': 1.0, + 'unit_of_measurement': '', + }), + 'context': , + 'entity_id': 'number.smart_white_noise_machine_volume', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '17.0', + }) +# --- # name: test_platform_setup_and_discovery[number.sous_vide_cooking_temperature-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/tuya/snapshots/test_switch.ambr b/tests/components/tuya/snapshots/test_switch.ambr index 1b481daa945728..7df3249aa67d84 100644 --- a/tests/components/tuya/snapshots/test_switch.ambr +++ b/tests/components/tuya/snapshots/test_switch.ambr @@ -1070,6 +1070,55 @@ 'state': 'off', }) # --- +# name: test_platform_setup_and_discovery[switch.blissradia_snooze-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.blissradia_snooze', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:alarm-snooze', + 'original_name': 'Snooze', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'snooze', + 'unique_id': 'tuya.bfpewgk8r6fhmissdyzbsnooze', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[switch.blissradia_snooze-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'BlissRadia Snooze', + 'icon': 'mdi:alarm-snooze', + }), + 'context': , + 'entity_id': 'switch.blissradia_snooze', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- # name: test_platform_setup_and_discovery[switch.bree_power-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -7403,6 +7452,103 @@ 'state': 'unavailable', }) # --- +# name: test_platform_setup_and_discovery[switch.smart_white_noise_machine-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.smart_white_noise_machine', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'tuya.ri7eegdifufzdi54dyzbswitch', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[switch.smart_white_noise_machine-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Smart White Noise Machine', + }), + 'context': , + 'entity_id': 'switch.smart_white_noise_machine', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_platform_setup_and_discovery[switch.smart_white_noise_machine_music-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.smart_white_noise_machine_music', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:music', + 'original_name': 'Music', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'music', + 'unique_id': 'tuya.ri7eegdifufzdi54dyzbswitch_music', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[switch.smart_white_noise_machine_music-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Smart White Noise Machine Music', + 'icon': 'mdi:music', + }), + 'context': , + 'entity_id': 'switch.smart_white_noise_machine_music', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- # name: test_platform_setup_and_discovery[switch.smoke_alarm_mute-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ From 17fe1477266ed585630ab1901f08f2774b191d11 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Fri, 12 Sep 2025 19:08:29 +0200 Subject: [PATCH 15/30] Add support for Tuya szjcy category (water quality sensors) (#152020) --- homeassistant/components/tuya/const.py | 1 + homeassistant/components/tuya/sensor.py | 15 ++ homeassistant/components/tuya/strings.json | 3 + .../components/tuya/snapshots/test_init.ambr | 2 +- .../tuya/snapshots/test_sensor.ambr | 161 ++++++++++++++++++ 5 files changed, 181 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/tuya/const.py b/homeassistant/components/tuya/const.py index 19c7ffac7dd365..862e10c6fa14c9 100644 --- a/homeassistant/components/tuya/const.py +++ b/homeassistant/components/tuya/const.py @@ -386,6 +386,7 @@ class DPCode(StrEnum): SWITCH_VERTICAL = "switch_vertical" # Vertical swing flap switch SWITCH_VOICE = "switch_voice" # Voice switch TARGET_DIS_CLOSEST = "target_dis_closest" # Closest target distance + TDS_IN = "tds_in" # Total dissolved solids TEMP = "temp" # Temperature setting TEMP_BOILING_C = "temp_boiling_c" TEMP_BOILING_F = "temp_boiling_f" diff --git a/homeassistant/components/tuya/sensor.py b/homeassistant/components/tuya/sensor.py index cac5d17e74dfae..021830b2073426 100644 --- a/homeassistant/components/tuya/sensor.py +++ b/homeassistant/components/tuya/sensor.py @@ -1161,6 +1161,21 @@ class TuyaSensorEntityDescription(SensorEntityDescription): state_class=SensorStateClass.MEASUREMENT, ), ), + # Water tester + "szjcy": ( + TuyaSensorEntityDescription( + key=DPCode.TDS_IN, + translation_key="total_dissolved_solids", + state_class=SensorStateClass.MEASUREMENT, + ), + TuyaSensorEntityDescription( + key=DPCode.TEMP_CURRENT, + translation_key="temperature", + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + ), + *BATTERY_SENSORS, + ), # Fingerbot "szjqr": BATTERY_SENSORS, # IoT Switch diff --git a/homeassistant/components/tuya/strings.json b/homeassistant/components/tuya/strings.json index 7781fc926ca047..bdb10d7984b26d 100644 --- a/homeassistant/components/tuya/strings.json +++ b/homeassistant/components/tuya/strings.json @@ -820,6 +820,9 @@ }, "supply_frequency": { "name": "Supply frequency" + }, + "total_dissolved_solids": { + "name": "Total dissolved solids" } }, "switch": { diff --git a/tests/components/tuya/snapshots/test_init.ambr b/tests/components/tuya/snapshots/test_init.ambr index 533aee7d687258..2a3f5687c525d0 100644 --- a/tests/components/tuya/snapshots/test_init.ambr +++ b/tests/components/tuya/snapshots/test_init.ambr @@ -330,7 +330,7 @@ 'labels': set({ }), 'manufacturer': 'Tuya', - 'model': 'YINMIK Water Quality Tester (unsupported)', + 'model': 'YINMIK Water Quality Tester', 'model_id': 'u5xgcpcngk3pfxb4', 'name': 'YINMIK Water Quality Tester', 'name_by_user': None, diff --git a/tests/components/tuya/snapshots/test_sensor.ambr b/tests/components/tuya/snapshots/test_sensor.ambr index 464bdd353ec7b5..1ec5a6c32310de 100644 --- a/tests/components/tuya/snapshots/test_sensor.ambr +++ b/tests/components/tuya/snapshots/test_sensor.ambr @@ -19011,3 +19011,164 @@ 'state': '231.4', }) # --- +# name: test_platform_setup_and_discovery[sensor.yinmik_water_quality_tester_battery-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.yinmik_water_quality_tester_battery', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Battery', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'battery', + 'unique_id': 'tuya.4bxfp3kgncpcgx5uycjzsbattery_percentage', + 'unit_of_measurement': '%', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.yinmik_water_quality_tester_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'YINMIK Water Quality Tester Battery', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.yinmik_water_quality_tester_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.yinmik_water_quality_tester_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.yinmik_water_quality_tester_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Temperature', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'temperature', + 'unique_id': 'tuya.4bxfp3kgncpcgx5uycjzstemp_current', + 'unit_of_measurement': , + }) +# --- +# name: test_platform_setup_and_discovery[sensor.yinmik_water_quality_tester_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'YINMIK Water Quality Tester Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.yinmik_water_quality_tester_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '41.2', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.yinmik_water_quality_tester_total_dissolved_solids-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.yinmik_water_quality_tester_total_dissolved_solids', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Total dissolved solids', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'total_dissolved_solids', + 'unique_id': 'tuya.4bxfp3kgncpcgx5uycjzstds_in', + 'unit_of_measurement': 'ppt', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.yinmik_water_quality_tester_total_dissolved_solids-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'YINMIK Water Quality Tester Total dissolved solids', + 'state_class': , + 'unit_of_measurement': 'ppt', + }), + 'context': , + 'entity_id': 'sensor.yinmik_water_quality_tester_total_dissolved_solids', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.476', + }) +# --- From c4bea5616cc6790c16b7d63fd119efe97dd0423e Mon Sep 17 00:00:00 2001 From: Yuxin Wang Date: Fri, 12 Sep 2025 13:09:46 -0400 Subject: [PATCH 16/30] Upgrade aioapcaccess to 1.0.0 (#151844) --- .../components/apcupsd/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/apcupsd/__init__.py | 115 +++++++++--------- 4 files changed, 58 insertions(+), 63 deletions(-) diff --git a/homeassistant/components/apcupsd/manifest.json b/homeassistant/components/apcupsd/manifest.json index 65a1e7010cf6d2..e0aff037d9ea47 100644 --- a/homeassistant/components/apcupsd/manifest.json +++ b/homeassistant/components/apcupsd/manifest.json @@ -7,5 +7,5 @@ "iot_class": "local_polling", "loggers": ["apcaccess"], "quality_scale": "platinum", - "requirements": ["aioapcaccess==0.4.2"] + "requirements": ["aioapcaccess==1.0.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index cd29b2345e51fd..5f84d610b12b72 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -192,7 +192,7 @@ aioamazondevices==6.0.0 aioambient==2024.08.0 # homeassistant.components.apcupsd -aioapcaccess==0.4.2 +aioapcaccess==1.0.0 # homeassistant.components.aquacell aioaquacell==0.2.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 2778ab8af60dd1..1f608db5cd2a09 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -180,7 +180,7 @@ aioamazondevices==6.0.0 aioambient==2024.08.0 # homeassistant.components.apcupsd -aioapcaccess==0.4.2 +aioapcaccess==1.0.0 # homeassistant.components.aquacell aioaquacell==0.2.0 diff --git a/tests/components/apcupsd/__init__.py b/tests/components/apcupsd/__init__.py index 0efeac0e45c9f1..ac18d4e4277db1 100644 --- a/tests/components/apcupsd/__init__.py +++ b/tests/components/apcupsd/__init__.py @@ -2,73 +2,68 @@ from __future__ import annotations -from collections import OrderedDict from typing import Final from homeassistant.const import CONF_HOST, CONF_PORT CONF_DATA: Final = {CONF_HOST: "test", CONF_PORT: 1234} -MOCK_STATUS: Final = OrderedDict( - [ - ("APC", "001,038,0985"), - ("DATE", "1970-01-01 00:00:00 0000"), - ("VERSION", "3.14.14 (31 May 2016) unknown"), - ("CABLE", "USB Cable"), - ("DRIVER", "USB UPS Driver"), - ("UPSMODE", "Stand Alone"), - ("UPSNAME", "MyUPS"), - ("MODEL", "Back-UPS ES 600"), - ("STATUS", "ONLINE"), - ("LINEV", "124.0 Volts"), - ("LOADPCT", "14.0 Percent"), - ("BCHARGE", "100.0 Percent"), - ("TIMELEFT", "51.0 Minutes"), - ("NOMAPNT", "60.0 VA"), - ("ITEMP", "34.6 C Internal"), - ("MBATTCHG", "5 Percent"), - ("MINTIMEL", "3 Minutes"), - ("MAXTIME", "0 Seconds"), - ("SENSE", "Medium"), - ("LOTRANS", "92.0 Volts"), - ("HITRANS", "139.0 Volts"), - ("ALARMDEL", "30 Seconds"), - ("BATTV", "13.7 Volts"), - ("OUTCURNT", "0.88 Amps"), - ("LASTXFER", "Automatic or explicit self test"), - ("NUMXFERS", "1"), - ("XONBATT", "1970-01-01 00:00:00 0000"), - ("TONBATT", "0 Seconds"), - ("CUMONBATT", "8 Seconds"), - ("XOFFBATT", "1970-01-01 00:00:00 0000"), - ("LASTSTEST", "1970-01-01 00:00:00 0000"), - ("SELFTEST", "NO"), - ("STESTI", "7 days"), - ("STATFLAG", "0x05000008"), - ("SERIALNO", "XXXXXXXXXXXX"), - ("BATTDATE", "1970-01-01"), - ("NOMINV", "120 Volts"), - ("NOMBATTV", "12.0 Volts"), - ("NOMPOWER", "330 Watts"), - ("FIRMWARE", "928.a8 .D USB FW:a8"), - ("END APC", "1970-01-01 00:00:00 0000"), - ] -) +MOCK_STATUS: Final = { + "APC": "001,038,0985", + "DATE": "1970-01-01 00:00:00 0000", + "VERSION": "3.14.14 (31 May 2016) unknown", + "CABLE": "USB Cable", + "DRIVER": "USB UPS Driver", + "UPSMODE": "Stand Alone", + "UPSNAME": "MyUPS", + "MODEL": "Back-UPS ES 600", + "STATUS": "ONLINE", + "LINEV": "124.0 Volts", + "LOADPCT": "14.0 Percent", + "BCHARGE": "100.0 Percent", + "TIMELEFT": "51.0 Minutes", + "NOMAPNT": "60.0 VA", + "ITEMP": "34.6 C Internal", + "MBATTCHG": "5 Percent", + "MINTIMEL": "3 Minutes", + "MAXTIME": "0 Seconds", + "SENSE": "Medium", + "LOTRANS": "92.0 Volts", + "HITRANS": "139.0 Volts", + "ALARMDEL": "30 Seconds", + "BATTV": "13.7 Volts", + "OUTCURNT": "0.88 Amps", + "LASTXFER": "Automatic or explicit self test", + "NUMXFERS": "1", + "XONBATT": "1970-01-01 00:00:00 0000", + "TONBATT": "0 Seconds", + "CUMONBATT": "8 Seconds", + "XOFFBATT": "1970-01-01 00:00:00 0000", + "LASTSTEST": "1970-01-01 00:00:00 0000", + "SELFTEST": "NO", + "STESTI": "7 days", + "STATFLAG": "0x05000008", + "SERIALNO": "XXXXXXXXXXXX", + "BATTDATE": "1970-01-01", + "NOMINV": "120 Volts", + "NOMBATTV": "12.0 Volts", + "NOMPOWER": "330 Watts", + "FIRMWARE": "928.a8 .D USB FW:a8", + "END APC": "1970-01-01 00:00:00 0000", +} # Minimal status adapted from http://www.apcupsd.org/manual/manual.html#apcaccess-test. # Most importantly, the "MODEL" and "SERIALNO" fields are removed to test the ability # of the integration to handle such cases. -MOCK_MINIMAL_STATUS: Final = OrderedDict( - [ - ("APC", "001,012,0319"), - ("DATE", "1970-01-01 00:00:00 0000"), - ("RELEASE", "3.8.5"), - ("CABLE", "APC Cable 940-0128A"), - ("UPSMODE", "Stand Alone"), - ("STARTTIME", "1970-01-01 00:00:00 0000"), - ("LINEFAIL", "OK"), - ("BATTSTAT", "OK"), - ("STATFLAG", "0x008"), - ("END APC", "1970-01-01 00:00:00 0000"), - ] -) +MOCK_MINIMAL_STATUS: Final = { + "APC": "001,012,0319", + "DATE": "1970-01-01 00:00:00 0000", + "RELEASE": "3.8.5", + "CABLE": "APC Cable 940-0128A", + "UPSMODE": "Stand Alone", + "STARTTIME": "1970-01-01 00:00:00 0000", + "LINEFAIL": "OK", + "BATTSTAT": "OK", + "STATFLAG": "0x008", + "END APC": "1970-01-01 00:00:00 0000", +} From c5ff7ed1c96c9b38d124cebe1aa12c37599b23c6 Mon Sep 17 00:00:00 2001 From: jan iversen Date: Fri, 12 Sep 2025 19:15:15 +0200 Subject: [PATCH 17/30] Remove self._lock in modbus. (#151997) Co-authored-by: Franck Nijhof --- homeassistant/components/modbus/modbus.py | 32 +++++++++-------------- 1 file changed, 13 insertions(+), 19 deletions(-) diff --git a/homeassistant/components/modbus/modbus.py b/homeassistant/components/modbus/modbus.py index a1804efbca0394..e873d53878d214 100644 --- a/homeassistant/components/modbus/modbus.py +++ b/homeassistant/components/modbus/modbus.py @@ -253,7 +253,6 @@ def __init__(self, hass: HomeAssistant, client_config: dict[str, Any]) -> None: self._client: ( AsyncModbusSerialClient | AsyncModbusTcpClient | AsyncModbusUdpClient | None ) = None - self._lock = asyncio.Lock() self.event_connected = asyncio.Event() self.hass = hass self.name = client_config[CONF_NAME] @@ -362,16 +361,13 @@ async def async_close(self) -> None: if not self._connect_task.done(): self._connect_task.cancel() - async with self._lock: - if self._client: - try: - self._client.close() - except ModbusException as exception_error: - self._log_error(str(exception_error)) - del self._client - self._client = None - message = f"modbus {self.name} communication closed" - _LOGGER.info(message) + if self._client: + try: + self._client.close() + except ModbusException as exception_error: + self._log_error(str(exception_error)) + self._client = None + _LOGGER.info(f"modbus {self.name} communication closed") async def low_level_pb_call( self, slave: int | None, address: int, value: int | list[int], use_call: str @@ -417,11 +413,9 @@ async def async_pb_call( use_call: str, ) -> ModbusPDU | None: """Convert async to sync pymodbus call.""" - async with self._lock: - if not self._client: - return None - result = await self.low_level_pb_call(unit, address, value, use_call) - if self._msg_wait: - # small delay until next request/response - await asyncio.sleep(self._msg_wait) - return result + if not self._client: + return None + result = await self.low_level_pb_call(unit, address, value, use_call) + if self._msg_wait: + await asyncio.sleep(self._msg_wait) + return result From 2ddbcd560e64579d1fc70390b420535ebb903b92 Mon Sep 17 00:00:00 2001 From: Shay Levy Date: Fri, 12 Sep 2025 20:16:54 +0300 Subject: [PATCH 18/30] Add Shelly support for virtual buttons (#151940) --- homeassistant/components/shelly/button.py | 77 +++++++++++++++++-- homeassistant/components/shelly/const.py | 3 +- .../components/shelly/coordinator.py | 5 ++ .../shelly/snapshots/test_button.ambr | 48 ++++++++++++ tests/components/shelly/test_button.py | 66 +++++++++++++++- tests/components/shelly/test_coordinator.py | 51 ++++++++++++ 6 files changed, 243 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/shelly/button.py b/homeassistant/components/shelly/button.py index bb8c9971433a20..af34119290b5e4 100644 --- a/homeassistant/components/shelly/button.py +++ b/homeassistant/components/shelly/button.py @@ -11,6 +11,7 @@ from aioshelly.exceptions import DeviceConnectionError, InvalidAuthError, RpcCallError from homeassistant.components.button import ( + DOMAIN as BUTTON_PLATFORM, ButtonDeviceClass, ButtonEntity, ButtonEntityDescription, @@ -26,7 +27,14 @@ from .const import DOMAIN, LOGGER, SHELLY_GAS_MODELS from .coordinator import ShellyBlockCoordinator, ShellyConfigEntry, ShellyRpcCoordinator from .entity import get_entity_block_device_info, get_entity_rpc_device_info -from .utils import get_blu_trv_device_info, get_device_entry_gen, get_rpc_key_ids +from .utils import ( + async_remove_orphaned_entities, + get_blu_trv_device_info, + get_device_entry_gen, + get_rpc_entity_name, + get_rpc_key_ids, + get_virtual_component_ids, +) PARALLEL_UPDATES = 0 @@ -87,6 +95,13 @@ class ShellyButtonDescription[ ), ] +VIRTUAL_BUTTONS: Final[list[ShellyButtonDescription]] = [ + ShellyButtonDescription[ShellyRpcCoordinator]( + key="button", + press_action="single_push", + ) +] + @callback def async_migrate_unique_ids( @@ -138,7 +153,7 @@ async def async_setup_entry( hass, config_entry.entry_id, partial(async_migrate_unique_ids, coordinator) ) - entities: list[ShellyButton | ShellyBluTrvButton] = [] + entities: list[ShellyButton | ShellyBluTrvButton | ShellyVirtualButton] = [] entities.extend( ShellyButton(coordinator, button) @@ -146,10 +161,20 @@ async def async_setup_entry( if button.supported(coordinator) ) - if blutrv_key_ids := get_rpc_key_ids(coordinator.device.status, BLU_TRV_IDENTIFIER): - if TYPE_CHECKING: - assert isinstance(coordinator, ShellyRpcCoordinator) + if not isinstance(coordinator, ShellyRpcCoordinator): + async_add_entities(entities) + return + + # add virtual buttons + if virtual_button_ids := get_rpc_key_ids(coordinator.device.status, "button"): + entities.extend( + ShellyVirtualButton(coordinator, button, id_) + for id_ in virtual_button_ids + for button in VIRTUAL_BUTTONS + ) + # add BLU TRV buttons + if blutrv_key_ids := get_rpc_key_ids(coordinator.device.status, BLU_TRV_IDENTIFIER): entities.extend( ShellyBluTrvButton(coordinator, button, id_) for id_ in blutrv_key_ids @@ -159,6 +184,19 @@ async def async_setup_entry( async_add_entities(entities) + # the user can remove virtual components from the device configuration, so + # we need to remove orphaned entities + virtual_button_component_ids = get_virtual_component_ids( + coordinator.device.config, BUTTON_PLATFORM + ) + async_remove_orphaned_entities( + hass, + config_entry.entry_id, + coordinator.mac, + BUTTON_PLATFORM, + virtual_button_component_ids, + ) + class ShellyBaseButton( CoordinatorEntity[ShellyRpcCoordinator | ShellyBlockCoordinator], ButtonEntity @@ -273,3 +311,32 @@ async def _press_method(self) -> None: assert method is not None await method(self._id) + + +class ShellyVirtualButton(ShellyBaseButton): + """Defines a Shelly virtual component button.""" + + def __init__( + self, + coordinator: ShellyRpcCoordinator, + description: ShellyButtonDescription, + _id: int, + ) -> None: + """Initialize Shelly virtual component button.""" + super().__init__(coordinator, description) + + self._attr_unique_id = f"{coordinator.mac}-{description.key}:{_id}" + self._attr_device_info = get_entity_rpc_device_info(coordinator) + self._attr_name = get_rpc_entity_name( + coordinator.device, f"{description.key}:{_id}" + ) + self._id = _id + + async def _press_method(self) -> None: + """Press method.""" + if TYPE_CHECKING: + assert isinstance(self.coordinator, ShellyRpcCoordinator) + + await self.coordinator.device.button_trigger( + self._id, self.entity_description.press_action + ) diff --git a/homeassistant/components/shelly/const.py b/homeassistant/components/shelly/const.py index bfa4718fb2e403..7a88f0d7c8db23 100644 --- a/homeassistant/components/shelly/const.py +++ b/homeassistant/components/shelly/const.py @@ -265,9 +265,10 @@ class BLEScannerMode(StrEnum): CONF_GEN = "gen" -VIRTUAL_COMPONENTS = ("boolean", "enum", "input", "number", "text") +VIRTUAL_COMPONENTS = ("boolean", "button", "enum", "input", "number", "text") VIRTUAL_COMPONENTS_MAP = { "binary_sensor": {"types": ["boolean"], "modes": ["label"]}, + "button": {"types": ["button"], "modes": ["button"]}, "number": {"types": ["number"], "modes": ["field", "slider"]}, "select": {"types": ["enum"], "modes": ["dropdown"]}, "sensor": {"types": ["enum", "number", "text"], "modes": ["label"]}, diff --git a/homeassistant/components/shelly/coordinator.py b/homeassistant/components/shelly/coordinator.py index eba6b846fe4044..69c2d5c33de17b 100644 --- a/homeassistant/components/shelly/coordinator.py +++ b/homeassistant/components/shelly/coordinator.py @@ -631,6 +631,11 @@ def _async_device_event_handler(self, event_data: dict[str, Any]) -> None: """Handle device events.""" events: list[dict[str, Any]] = event_data["events"] for event in events: + # filter out button events as they are triggered by button entities + component = event.get("component") + if component is not None and component.startswith("button"): + continue + event_type = event.get("event") if event_type is None: continue diff --git a/tests/components/shelly/snapshots/test_button.ambr b/tests/components/shelly/snapshots/test_button.ambr index 09c2c5f3d8da8d..cd0f88e37972d8 100644 --- a/tests/components/shelly/snapshots/test_button.ambr +++ b/tests/components/shelly/snapshots/test_button.ambr @@ -96,3 +96,51 @@ 'state': 'unknown', }) # --- +# name: test_rpc_device_virtual_button[button.test_name_button-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': None, + 'entity_id': 'button.test_name_button', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Button', + 'platform': 'shelly', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456789ABC-button:200', + 'unit_of_measurement': None, + }) +# --- +# name: test_rpc_device_virtual_button[button.test_name_button-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test name Button', + }), + 'context': , + 'entity_id': 'button.test_name_button', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- diff --git a/tests/components/shelly/test_button.py b/tests/components/shelly/test_button.py index 8d355098463c4d..3bf70f20f2e580 100644 --- a/tests/components/shelly/test_button.py +++ b/tests/components/shelly/test_button.py @@ -1,5 +1,6 @@ """Tests for Shelly button platform.""" +from copy import deepcopy from unittest.mock import Mock from aioshelly.const import MODEL_BLU_GATEWAY_G3 @@ -13,9 +14,10 @@ from homeassistant.const import ATTR_ENTITY_ID, STATE_UNKNOWN from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.device_registry import DeviceRegistry from homeassistant.helpers.entity_registry import EntityRegistry -from . import init_integration +from . import init_integration, register_device, register_entity async def test_block_button( @@ -278,3 +280,65 @@ async def test_rpc_blu_trv_button_auth_error( assert "context" in flow assert flow["context"].get("source") == SOURCE_REAUTH assert flow["context"].get("entry_id") == entry.entry_id + + +async def test_rpc_device_virtual_button( + hass: HomeAssistant, + entity_registry: EntityRegistry, + mock_rpc_device: Mock, + monkeypatch: pytest.MonkeyPatch, + snapshot: SnapshotAssertion, +) -> None: + """Test a virtual button for RPC device.""" + config = deepcopy(mock_rpc_device.config) + config["button:200"] = { + "name": "Button", + "meta": {"ui": {"view": "button"}}, + } + monkeypatch.setattr(mock_rpc_device, "config", config) + + status = deepcopy(mock_rpc_device.status) + status["button:200"] = {"value": None} + monkeypatch.setattr(mock_rpc_device, "status", status) + + await init_integration(hass, 3) + entity_id = "button.test_name_button" + + assert (state := hass.states.get(entity_id)) + assert state == snapshot(name=f"{entity_id}-state") + + assert (entry := entity_registry.async_get(entity_id)) + assert entry == snapshot(name=f"{entity_id}-entry") + + await hass.services.async_call( + BUTTON_DOMAIN, + SERVICE_PRESS, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + mock_rpc_device.button_trigger.assert_called_once_with(200, "single_push") + + +async def test_rpc_remove_virtual_button_when_orphaned( + hass: HomeAssistant, + entity_registry: EntityRegistry, + device_registry: DeviceRegistry, + mock_rpc_device: Mock, +) -> None: + """Check whether the virtual button will be removed if it has been removed from the device configuration.""" + config_entry = await init_integration(hass, 3, skip_setup=True) + device_entry = register_device(device_registry, config_entry) + entity_id = register_entity( + hass, + BUTTON_DOMAIN, + "test_name_button_200", + "button:200", + config_entry, + device_id=device_entry.id, + ) + + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + entry = entity_registry.async_get(entity_id) + assert not entry diff --git a/tests/components/shelly/test_coordinator.py b/tests/components/shelly/test_coordinator.py index ff61eda626f75f..e4549d9c4a0dc7 100644 --- a/tests/components/shelly/test_coordinator.py +++ b/tests/components/shelly/test_coordinator.py @@ -553,6 +553,57 @@ async def test_rpc_click_event( } +async def test_rpc_ignore_virtual_click_event( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + mock_rpc_device: Mock, + events: list[Event], + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Test RPC virtual click events are ignored as they are triggered by the integration.""" + await init_integration(hass, 2) + + # Generate a virtual button event + inject_rpc_device_event( + monkeypatch, + mock_rpc_device, + { + "events": [ + { + "component": "button:200", + "id": 200, + "event": "single_push", + "ts": 1757358109.89, + } + ], + "ts": 757358109.89, + }, + ) + await hass.async_block_till_done() + + assert len(events) == 0 + + # Generate valid event + inject_rpc_device_event( + monkeypatch, + mock_rpc_device, + { + "events": [ + { + "data": [], + "event": "single_push", + "id": 0, + "ts": 1668522399.2, + } + ], + "ts": 1668522399.2, + }, + ) + await hass.async_block_till_done() + + assert len(events) == 1 + + async def test_rpc_update_entry_sleep_period( hass: HomeAssistant, freezer: FrozenDateTimeFactory, From 14ebb6cd746795abf62eacad8a49f376e8ab644d Mon Sep 17 00:00:00 2001 From: Simon Lamon <32477463+silamon@users.noreply.github.com> Date: Fri, 12 Sep 2025 19:20:20 +0200 Subject: [PATCH 19/30] Pin SHA for all github actions (#151939) --- .github/workflows/builder.yml | 44 ++--- .github/workflows/ci.yaml | 168 +++++++++--------- .github/workflows/codeql.yml | 6 +- .github/workflows/detect-duplicate-issues.yml | 8 +- .../workflows/detect-non-english-issues.yml | 6 +- .github/workflows/lock.yml | 2 +- .github/workflows/restrict-task-creation.yml | 2 +- .github/workflows/stale.yml | 6 +- .github/workflows/translations.yml | 4 +- .github/workflows/wheels.yml | 34 ++-- 10 files changed, 140 insertions(+), 140 deletions(-) diff --git a/.github/workflows/builder.yml b/.github/workflows/builder.yml index 63cafce6c731f4..81a327424feaea 100644 --- a/.github/workflows/builder.yml +++ b/.github/workflows/builder.yml @@ -27,12 +27,12 @@ jobs: publish: ${{ steps.version.outputs.publish }} steps: - name: Checkout the repository - uses: actions/checkout@v5.0.0 + uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 with: fetch-depth: 0 - name: Set up Python ${{ env.DEFAULT_PYTHON }} - uses: actions/setup-python@v6.0.0 + uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c # v6.0.0 with: python-version: ${{ env.DEFAULT_PYTHON }} @@ -69,7 +69,7 @@ jobs: run: find ./homeassistant/components/*/translations -name "*.json" | tar zcvf translations.tar.gz -T - - name: Upload translations - uses: actions/upload-artifact@v4.6.2 + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: name: translations path: translations.tar.gz @@ -90,11 +90,11 @@ jobs: arch: ${{ fromJson(needs.init.outputs.architectures) }} steps: - name: Checkout the repository - uses: actions/checkout@v5.0.0 + uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 - name: Download nightly wheels of frontend if: needs.init.outputs.channel == 'dev' - uses: dawidd6/action-download-artifact@v11 + uses: dawidd6/action-download-artifact@ac66b43f0e6a346234dd65d4d0c8fbb31cb316e5 # v11 with: github_token: ${{secrets.GITHUB_TOKEN}} repo: home-assistant/frontend @@ -105,7 +105,7 @@ jobs: - name: Download nightly wheels of intents if: needs.init.outputs.channel == 'dev' - uses: dawidd6/action-download-artifact@v11 + uses: dawidd6/action-download-artifact@ac66b43f0e6a346234dd65d4d0c8fbb31cb316e5 # v11 with: github_token: ${{secrets.GITHUB_TOKEN}} repo: OHF-Voice/intents-package @@ -116,7 +116,7 @@ jobs: - name: Set up Python ${{ env.DEFAULT_PYTHON }} if: needs.init.outputs.channel == 'dev' - uses: actions/setup-python@v6.0.0 + uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c # v6.0.0 with: python-version: ${{ env.DEFAULT_PYTHON }} @@ -175,7 +175,7 @@ jobs: sed -i "s|pykrakenapi|# pykrakenapi|g" requirements_all.txt - name: Download translations - uses: actions/download-artifact@v5.0.0 + uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0 with: name: translations @@ -190,14 +190,14 @@ jobs: echo "${{ github.sha }};${{ github.ref }};${{ github.event_name }};${{ github.actor }}" > rootfs/OFFICIAL_IMAGE - name: Login to GitHub Container Registry - uses: docker/login-action@v3.5.0 + uses: docker/login-action@184bdaa0721073962dff0199f1fb9940f07167d1 # v3.5.0 with: registry: ghcr.io username: ${{ github.repository_owner }} password: ${{ secrets.GITHUB_TOKEN }} - name: Build base image - uses: home-assistant/builder@2025.03.0 + uses: home-assistant/builder@71885366c80f6ead6ae8c364b61d910e0dc5addc # 2025.03.0 with: args: | $BUILD_ARGS \ @@ -242,7 +242,7 @@ jobs: - green steps: - name: Checkout the repository - uses: actions/checkout@v5.0.0 + uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 - name: Set build additional args run: | @@ -256,14 +256,14 @@ jobs: fi - name: Login to GitHub Container Registry - uses: docker/login-action@v3.5.0 + uses: docker/login-action@184bdaa0721073962dff0199f1fb9940f07167d1 # v3.5.0 with: registry: ghcr.io username: ${{ github.repository_owner }} password: ${{ secrets.GITHUB_TOKEN }} - name: Build base image - uses: home-assistant/builder@2025.03.0 + uses: home-assistant/builder@71885366c80f6ead6ae8c364b61d910e0dc5addc # 2025.03.0 with: args: | $BUILD_ARGS \ @@ -279,7 +279,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout the repository - uses: actions/checkout@v5.0.0 + uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 - name: Initialize git uses: home-assistant/actions/helpers/git-init@master @@ -321,23 +321,23 @@ jobs: registry: ["ghcr.io/home-assistant", "docker.io/homeassistant"] steps: - name: Checkout the repository - uses: actions/checkout@v5.0.0 + uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 - name: Install Cosign - uses: sigstore/cosign-installer@v3.9.2 + uses: sigstore/cosign-installer@d58896d6a1865668819e1d91763c7751a165e159 # v3.9.2 with: cosign-release: "v2.2.3" - name: Login to DockerHub if: matrix.registry == 'docker.io/homeassistant' - uses: docker/login-action@v3.5.0 + uses: docker/login-action@184bdaa0721073962dff0199f1fb9940f07167d1 # v3.5.0 with: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} - name: Login to GitHub Container Registry if: matrix.registry == 'ghcr.io/home-assistant' - uses: docker/login-action@v3.5.0 + uses: docker/login-action@184bdaa0721073962dff0199f1fb9940f07167d1 # v3.5.0 with: registry: ghcr.io username: ${{ github.repository_owner }} @@ -454,15 +454,15 @@ jobs: if: github.repository_owner == 'home-assistant' && needs.init.outputs.publish == 'true' steps: - name: Checkout the repository - uses: actions/checkout@v5.0.0 + uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 - name: Set up Python ${{ env.DEFAULT_PYTHON }} - uses: actions/setup-python@v6.0.0 + uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c # v6.0.0 with: python-version: ${{ env.DEFAULT_PYTHON }} - name: Download translations - uses: actions/download-artifact@v5.0.0 + uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0 with: name: translations @@ -480,7 +480,7 @@ jobs: python -m build - name: Upload package to PyPI - uses: pypa/gh-action-pypi-publish@v1.13.0 + uses: pypa/gh-action-pypi-publish@ed0c53931b1dc9bd32cbe73a98c7f6766f8a527e # v1.13.0 with: skip-existing: true diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 0d465f428a6219..41a2c1c7ea1ec6 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -98,7 +98,7 @@ jobs: runs-on: ubuntu-24.04 steps: - name: Check out code from GitHub - uses: actions/checkout@v5.0.0 + uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 - name: Generate partial Python venv restore key id: generate_python_cache_key run: | @@ -120,7 +120,7 @@ jobs: run: | echo "key=$(lsb_release -rs)-apt-${{ env.CACHE_VERSION }}-${{ env.HA_SHORT_VERSION }}" >> $GITHUB_OUTPUT - name: Filter for core changes - uses: dorny/paths-filter@v3.0.2 + uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3.0.2 id: core with: filters: .core_files.yaml @@ -135,7 +135,7 @@ jobs: echo "Result:" cat .integration_paths.yaml - name: Filter for integration changes - uses: dorny/paths-filter@v3.0.2 + uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3.0.2 id: integrations with: filters: .integration_paths.yaml @@ -254,16 +254,16 @@ jobs: - info steps: - name: Check out code from GitHub - uses: actions/checkout@v5.0.0 + uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 - name: Set up Python ${{ env.DEFAULT_PYTHON }} id: python - uses: actions/setup-python@v6.0.0 + uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c # v6.0.0 with: python-version: ${{ env.DEFAULT_PYTHON }} check-latest: true - name: Restore base Python virtual environment id: cache-venv - uses: actions/cache@v4.2.4 + uses: actions/cache@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4 with: path: venv key: >- @@ -279,7 +279,7 @@ jobs: uv pip install "$(cat requirements_test.txt | grep pre-commit)" - name: Restore pre-commit environment from cache id: cache-precommit - uses: actions/cache@v4.2.4 + uses: actions/cache@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4 with: path: ${{ env.PRE_COMMIT_CACHE }} lookup-only: true @@ -300,16 +300,16 @@ jobs: - pre-commit steps: - name: Check out code from GitHub - uses: actions/checkout@v5.0.0 + uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 - name: Set up Python ${{ env.DEFAULT_PYTHON }} - uses: actions/setup-python@v6.0.0 + uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c # v6.0.0 id: python with: python-version: ${{ env.DEFAULT_PYTHON }} check-latest: true - name: Restore base Python virtual environment id: cache-venv - uses: actions/cache/restore@v4.2.4 + uses: actions/cache/restore@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4 with: path: venv fail-on-cache-miss: true @@ -318,7 +318,7 @@ jobs: needs.info.outputs.pre-commit_cache_key }} - name: Restore pre-commit environment from cache id: cache-precommit - uses: actions/cache/restore@v4.2.4 + uses: actions/cache/restore@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4 with: path: ${{ env.PRE_COMMIT_CACHE }} fail-on-cache-miss: true @@ -340,16 +340,16 @@ jobs: - pre-commit steps: - name: Check out code from GitHub - uses: actions/checkout@v5.0.0 + uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 - name: Set up Python ${{ env.DEFAULT_PYTHON }} - uses: actions/setup-python@v6.0.0 + uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c # v6.0.0 id: python with: python-version: ${{ env.DEFAULT_PYTHON }} check-latest: true - name: Restore base Python virtual environment id: cache-venv - uses: actions/cache/restore@v4.2.4 + uses: actions/cache/restore@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4 with: path: venv fail-on-cache-miss: true @@ -358,7 +358,7 @@ jobs: needs.info.outputs.pre-commit_cache_key }} - name: Restore pre-commit environment from cache id: cache-precommit - uses: actions/cache/restore@v4.2.4 + uses: actions/cache/restore@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4 with: path: ${{ env.PRE_COMMIT_CACHE }} fail-on-cache-miss: true @@ -380,16 +380,16 @@ jobs: - pre-commit steps: - name: Check out code from GitHub - uses: actions/checkout@v5.0.0 + uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 - name: Set up Python ${{ env.DEFAULT_PYTHON }} - uses: actions/setup-python@v6.0.0 + uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c # v6.0.0 id: python with: python-version: ${{ env.DEFAULT_PYTHON }} check-latest: true - name: Restore base Python virtual environment id: cache-venv - uses: actions/cache/restore@v4.2.4 + uses: actions/cache/restore@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4 with: path: venv fail-on-cache-miss: true @@ -398,7 +398,7 @@ jobs: needs.info.outputs.pre-commit_cache_key }} - name: Restore pre-commit environment from cache id: cache-precommit - uses: actions/cache/restore@v4.2.4 + uses: actions/cache/restore@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4 with: path: ${{ env.PRE_COMMIT_CACHE }} fail-on-cache-miss: true @@ -470,7 +470,7 @@ jobs: - script/hassfest/docker/Dockerfile steps: - name: Check out code from GitHub - uses: actions/checkout@v5.0.0 + uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 - name: Register hadolint problem matcher run: | echo "::add-matcher::.github/workflows/matchers/hadolint.json" @@ -489,10 +489,10 @@ jobs: python-version: ${{ fromJSON(needs.info.outputs.python_versions) }} steps: - name: Check out code from GitHub - uses: actions/checkout@v5.0.0 + uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 - name: Set up Python ${{ matrix.python-version }} id: python - uses: actions/setup-python@v6.0.0 + uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c # v6.0.0 with: python-version: ${{ matrix.python-version }} check-latest: true @@ -505,7 +505,7 @@ jobs: env.HA_SHORT_VERSION }}-$(date -u '+%Y-%m-%dT%H:%M:%s')" >> $GITHUB_OUTPUT - name: Restore base Python virtual environment id: cache-venv - uses: actions/cache@v4.2.4 + uses: actions/cache@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4 with: path: venv key: >- @@ -513,7 +513,7 @@ jobs: needs.info.outputs.python_cache_key }} - name: Restore uv wheel cache if: steps.cache-venv.outputs.cache-hit != 'true' - uses: actions/cache@v4.2.4 + uses: actions/cache@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4 with: path: ${{ env.UV_CACHE_DIR }} key: >- @@ -585,7 +585,7 @@ jobs: python --version uv pip freeze >> pip_freeze.txt - name: Upload pip_freeze artifact - uses: actions/upload-artifact@v4.6.2 + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: name: pip-freeze-${{ matrix.python-version }} path: pip_freeze.txt @@ -631,16 +631,16 @@ jobs: -o Dir::State::Lists=${{ env.APT_LIST_CACHE_DIR }} \ libturbojpeg - name: Check out code from GitHub - uses: actions/checkout@v5.0.0 + uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 - name: Set up Python ${{ env.DEFAULT_PYTHON }} id: python - uses: actions/setup-python@v6.0.0 + uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c # v6.0.0 with: python-version: ${{ env.DEFAULT_PYTHON }} check-latest: true - name: Restore full Python ${{ env.DEFAULT_PYTHON }} virtual environment id: cache-venv - uses: actions/cache/restore@v4.2.4 + uses: actions/cache/restore@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4 with: path: venv fail-on-cache-miss: true @@ -664,16 +664,16 @@ jobs: - base steps: - name: Check out code from GitHub - uses: actions/checkout@v5.0.0 + uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 - name: Set up Python ${{ env.DEFAULT_PYTHON }} id: python - uses: actions/setup-python@v6.0.0 + uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c # v6.0.0 with: python-version: ${{ env.DEFAULT_PYTHON }} check-latest: true - name: Restore base Python virtual environment id: cache-venv - uses: actions/cache/restore@v4.2.4 + uses: actions/cache/restore@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4 with: path: venv fail-on-cache-miss: true @@ -698,9 +698,9 @@ jobs: && github.event_name == 'pull_request' steps: - name: Check out code from GitHub - uses: actions/checkout@v5.0.0 + uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 - name: Dependency review - uses: actions/dependency-review-action@v4.7.3 + uses: actions/dependency-review-action@595b5aeba73380359d98a5e087f648dbb0edce1b # v4.7.3 with: license-check: false # We use our own license audit checks @@ -721,16 +721,16 @@ jobs: python-version: ${{ fromJson(needs.info.outputs.python_versions) }} steps: - name: Check out code from GitHub - uses: actions/checkout@v5.0.0 + uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 - name: Set up Python ${{ matrix.python-version }} id: python - uses: actions/setup-python@v6.0.0 + uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c # v6.0.0 with: python-version: ${{ matrix.python-version }} check-latest: true - name: Restore full Python ${{ matrix.python-version }} virtual environment id: cache-venv - uses: actions/cache/restore@v4.2.4 + uses: actions/cache/restore@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4 with: path: venv fail-on-cache-miss: true @@ -742,7 +742,7 @@ jobs: . venv/bin/activate python -m script.licenses extract --output-file=licenses-${{ matrix.python-version }}.json - name: Upload licenses - uses: actions/upload-artifact@v4.6.2 + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: name: licenses-${{ github.run_number }}-${{ matrix.python-version }} path: licenses-${{ matrix.python-version }}.json @@ -764,16 +764,16 @@ jobs: - base steps: - name: Check out code from GitHub - uses: actions/checkout@v5.0.0 + uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 - name: Set up Python ${{ env.DEFAULT_PYTHON }} id: python - uses: actions/setup-python@v6.0.0 + uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c # v6.0.0 with: python-version: ${{ env.DEFAULT_PYTHON }} check-latest: true - name: Restore full Python ${{ env.DEFAULT_PYTHON }} virtual environment id: cache-venv - uses: actions/cache/restore@v4.2.4 + uses: actions/cache/restore@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4 with: path: venv fail-on-cache-miss: true @@ -811,16 +811,16 @@ jobs: - base steps: - name: Check out code from GitHub - uses: actions/checkout@v5.0.0 + uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 - name: Set up Python ${{ env.DEFAULT_PYTHON }} id: python - uses: actions/setup-python@v6.0.0 + uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c # v6.0.0 with: python-version: ${{ env.DEFAULT_PYTHON }} check-latest: true - name: Restore full Python ${{ env.DEFAULT_PYTHON }} virtual environment id: cache-venv - uses: actions/cache/restore@v4.2.4 + uses: actions/cache/restore@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4 with: path: venv fail-on-cache-miss: true @@ -856,10 +856,10 @@ jobs: - base steps: - name: Check out code from GitHub - uses: actions/checkout@v5.0.0 + uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 - name: Set up Python ${{ env.DEFAULT_PYTHON }} id: python - uses: actions/setup-python@v6.0.0 + uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c # v6.0.0 with: python-version: ${{ env.DEFAULT_PYTHON }} check-latest: true @@ -872,7 +872,7 @@ jobs: env.HA_SHORT_VERSION }}-$(date -u '+%Y-%m-%dT%H:%M:%s')" >> $GITHUB_OUTPUT - name: Restore full Python ${{ env.DEFAULT_PYTHON }} virtual environment id: cache-venv - uses: actions/cache/restore@v4.2.4 + uses: actions/cache/restore@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4 with: path: venv fail-on-cache-miss: true @@ -880,7 +880,7 @@ jobs: ${{ runner.os }}-${{ runner.arch }}-${{ steps.python.outputs.python-version }}-${{ needs.info.outputs.python_cache_key }} - name: Restore mypy cache - uses: actions/cache@v4.2.4 + uses: actions/cache@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4 with: path: .mypy_cache key: >- @@ -947,16 +947,16 @@ jobs: libturbojpeg \ libgammu-dev - name: Check out code from GitHub - uses: actions/checkout@v5.0.0 + uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 - name: Set up Python ${{ env.DEFAULT_PYTHON }} id: python - uses: actions/setup-python@v6.0.0 + uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c # v6.0.0 with: python-version: ${{ env.DEFAULT_PYTHON }} check-latest: true - name: Restore base Python virtual environment id: cache-venv - uses: actions/cache/restore@v4.2.4 + uses: actions/cache/restore@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4 with: path: venv fail-on-cache-miss: true @@ -968,7 +968,7 @@ jobs: . venv/bin/activate python -m script.split_tests ${{ needs.info.outputs.test_group_count }} tests - name: Upload pytest_buckets - uses: actions/upload-artifact@v4.6.2 + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: name: pytest_buckets path: pytest_buckets.txt @@ -1022,16 +1022,16 @@ jobs: libgammu-dev \ libxml2-utils - name: Check out code from GitHub - uses: actions/checkout@v5.0.0 + uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 - name: Set up Python ${{ matrix.python-version }} id: python - uses: actions/setup-python@v6.0.0 + uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c # v6.0.0 with: python-version: ${{ matrix.python-version }} check-latest: true - name: Restore full Python ${{ matrix.python-version }} virtual environment id: cache-venv - uses: actions/cache/restore@v4.2.4 + uses: actions/cache/restore@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4 with: path: venv fail-on-cache-miss: true @@ -1045,7 +1045,7 @@ jobs: run: | echo "::add-matcher::.github/workflows/matchers/pytest-slow.json" - name: Download pytest_buckets - uses: actions/download-artifact@v5.0.0 + uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0 with: name: pytest_buckets - name: Compile English translations @@ -1084,14 +1084,14 @@ jobs: 2>&1 | tee pytest-${{ matrix.python-version }}-${{ matrix.group }}.txt - name: Upload pytest output if: success() || failure() && steps.pytest-full.conclusion == 'failure' - uses: actions/upload-artifact@v4.6.2 + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: name: pytest-${{ github.run_number }}-${{ matrix.python-version }}-${{ matrix.group }} path: pytest-*.txt overwrite: true - name: Upload coverage artifact if: needs.info.outputs.skip_coverage != 'true' - uses: actions/upload-artifact@v4.6.2 + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: name: coverage-${{ matrix.python-version }}-${{ matrix.group }} path: coverage.xml @@ -1104,7 +1104,7 @@ jobs: mv "junit.xml-tmp" "junit.xml" - name: Upload test results artifact if: needs.info.outputs.skip_coverage != 'true' && !cancelled() - uses: actions/upload-artifact@v4.6.2 + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: name: test-results-full-${{ matrix.python-version }}-${{ matrix.group }} path: junit.xml @@ -1169,16 +1169,16 @@ jobs: libmariadb-dev-compat \ libxml2-utils - name: Check out code from GitHub - uses: actions/checkout@v5.0.0 + uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 - name: Set up Python ${{ matrix.python-version }} id: python - uses: actions/setup-python@v6.0.0 + uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c # v6.0.0 with: python-version: ${{ matrix.python-version }} check-latest: true - name: Restore full Python ${{ matrix.python-version }} virtual environment id: cache-venv - uses: actions/cache/restore@v4.2.4 + uses: actions/cache/restore@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4 with: path: venv fail-on-cache-miss: true @@ -1237,7 +1237,7 @@ jobs: 2>&1 | tee pytest-${{ matrix.python-version }}-${mariadb}.txt - name: Upload pytest output if: success() || failure() && steps.pytest-partial.conclusion == 'failure' - uses: actions/upload-artifact@v4.6.2 + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: name: pytest-${{ github.run_number }}-${{ matrix.python-version }}-${{ steps.pytest-partial.outputs.mariadb }} @@ -1245,7 +1245,7 @@ jobs: overwrite: true - name: Upload coverage artifact if: needs.info.outputs.skip_coverage != 'true' - uses: actions/upload-artifact@v4.6.2 + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: name: coverage-${{ matrix.python-version }}-${{ steps.pytest-partial.outputs.mariadb }} @@ -1259,7 +1259,7 @@ jobs: mv "junit.xml-tmp" "junit.xml" - name: Upload test results artifact if: needs.info.outputs.skip_coverage != 'true' && !cancelled() - uses: actions/upload-artifact@v4.6.2 + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: name: test-results-mariadb-${{ matrix.python-version }}-${{ steps.pytest-partial.outputs.mariadb }} @@ -1325,16 +1325,16 @@ jobs: sudo apt-get -y install \ postgresql-server-dev-14 - name: Check out code from GitHub - uses: actions/checkout@v5.0.0 + uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 - name: Set up Python ${{ matrix.python-version }} id: python - uses: actions/setup-python@v6.0.0 + uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c # v6.0.0 with: python-version: ${{ matrix.python-version }} check-latest: true - name: Restore full Python ${{ matrix.python-version }} virtual environment id: cache-venv - uses: actions/cache/restore@v4.2.4 + uses: actions/cache/restore@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4 with: path: venv fail-on-cache-miss: true @@ -1394,7 +1394,7 @@ jobs: 2>&1 | tee pytest-${{ matrix.python-version }}-${postgresql}.txt - name: Upload pytest output if: success() || failure() && steps.pytest-partial.conclusion == 'failure' - uses: actions/upload-artifact@v4.6.2 + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: name: pytest-${{ github.run_number }}-${{ matrix.python-version }}-${{ steps.pytest-partial.outputs.postgresql }} @@ -1402,7 +1402,7 @@ jobs: overwrite: true - name: Upload coverage artifact if: needs.info.outputs.skip_coverage != 'true' - uses: actions/upload-artifact@v4.6.2 + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: name: coverage-${{ matrix.python-version }}-${{ steps.pytest-partial.outputs.postgresql }} @@ -1416,7 +1416,7 @@ jobs: mv "junit.xml-tmp" "junit.xml" - name: Upload test results artifact if: needs.info.outputs.skip_coverage != 'true' && !cancelled() - uses: actions/upload-artifact@v4.6.2 + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: name: test-results-postgres-${{ matrix.python-version }}-${{ steps.pytest-partial.outputs.postgresql }} @@ -1437,14 +1437,14 @@ jobs: timeout-minutes: 10 steps: - name: Check out code from GitHub - uses: actions/checkout@v5.0.0 + uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 - name: Download all coverage artifacts - uses: actions/download-artifact@v5.0.0 + uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0 with: pattern: coverage-* - name: Upload coverage to Codecov if: needs.info.outputs.test_full_suite == 'true' - uses: codecov/codecov-action@v5.5.1 + uses: codecov/codecov-action@5a1091511ad55cbe89839c7260b706298ca349f7 # v5.5.1 with: fail_ci_if_error: true flags: full-suite @@ -1498,16 +1498,16 @@ jobs: libgammu-dev \ libxml2-utils - name: Check out code from GitHub - uses: actions/checkout@v5.0.0 + uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 - name: Set up Python ${{ matrix.python-version }} id: python - uses: actions/setup-python@v6.0.0 + uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c # v6.0.0 with: python-version: ${{ matrix.python-version }} check-latest: true - name: Restore full Python ${{ matrix.python-version }} virtual environment id: cache-venv - uses: actions/cache/restore@v4.2.4 + uses: actions/cache/restore@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4 with: path: venv fail-on-cache-miss: true @@ -1563,14 +1563,14 @@ jobs: 2>&1 | tee pytest-${{ matrix.python-version }}-${{ matrix.group }}.txt - name: Upload pytest output if: success() || failure() && steps.pytest-partial.conclusion == 'failure' - uses: actions/upload-artifact@v4.6.2 + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: name: pytest-${{ github.run_number }}-${{ matrix.python-version }}-${{ matrix.group }} path: pytest-*.txt overwrite: true - name: Upload coverage artifact if: needs.info.outputs.skip_coverage != 'true' - uses: actions/upload-artifact@v4.6.2 + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: name: coverage-${{ matrix.python-version }}-${{ matrix.group }} path: coverage.xml @@ -1583,7 +1583,7 @@ jobs: mv "junit.xml-tmp" "junit.xml" - name: Upload test results artifact if: needs.info.outputs.skip_coverage != 'true' && !cancelled() - uses: actions/upload-artifact@v4.6.2 + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: name: test-results-partial-${{ matrix.python-version }}-${{ matrix.group }} path: junit.xml @@ -1601,14 +1601,14 @@ jobs: timeout-minutes: 10 steps: - name: Check out code from GitHub - uses: actions/checkout@v5.0.0 + uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 - name: Download all coverage artifacts - uses: actions/download-artifact@v5.0.0 + uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0 with: pattern: coverage-* - name: Upload coverage to Codecov if: needs.info.outputs.test_full_suite == 'false' - uses: codecov/codecov-action@v5.5.1 + uses: codecov/codecov-action@5a1091511ad55cbe89839c7260b706298ca349f7 # v5.5.1 with: fail_ci_if_error: true token: ${{ secrets.CODECOV_TOKEN }} @@ -1628,11 +1628,11 @@ jobs: timeout-minutes: 10 steps: - name: Download all coverage artifacts - uses: actions/download-artifact@v5.0.0 + uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0 with: pattern: test-results-* - name: Upload test results to Codecov - uses: codecov/test-results-action@v1 + uses: codecov/test-results-action@47f89e9acb64b76debcd5ea40642d25a4adced9f # v1.1.1 with: fail_ci_if_error: true verbose: true diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 044aea8d2cff82..c3a5073d03898a 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -21,14 +21,14 @@ jobs: steps: - name: Check out code from GitHub - uses: actions/checkout@v5.0.0 + uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 - name: Initialize CodeQL - uses: github/codeql-action/init@v3.30.3 + uses: github/codeql-action/init@192325c86100d080feab897ff886c34abd4c83a3 # v3.30.3 with: languages: python - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@v3.30.3 + uses: github/codeql-action/analyze@192325c86100d080feab897ff886c34abd4c83a3 # v3.30.3 with: category: "/language:python" diff --git a/.github/workflows/detect-duplicate-issues.yml b/.github/workflows/detect-duplicate-issues.yml index 1997f1c02b0c63..801c4bb36bc95c 100644 --- a/.github/workflows/detect-duplicate-issues.yml +++ b/.github/workflows/detect-duplicate-issues.yml @@ -16,7 +16,7 @@ jobs: steps: - name: Check if integration label was added and extract details id: extract - uses: actions/github-script@v8 + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 with: script: | // Debug: Log the event payload @@ -113,7 +113,7 @@ jobs: - name: Fetch similar issues id: fetch_similar if: steps.extract.outputs.should_continue == 'true' - uses: actions/github-script@v8 + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 env: INTEGRATION_LABELS: ${{ steps.extract.outputs.integration_labels }} CURRENT_NUMBER: ${{ steps.extract.outputs.current_number }} @@ -231,7 +231,7 @@ jobs: - name: Detect duplicates using AI id: ai_detection if: steps.extract.outputs.should_continue == 'true' && steps.fetch_similar.outputs.has_similar == 'true' - uses: actions/ai-inference@v2.0.1 + uses: actions/ai-inference@a1c11829223a786afe3b5663db904a3aa1eac3a2 # v2.0.1 with: model: openai/gpt-4o system-prompt: | @@ -280,7 +280,7 @@ jobs: - name: Post duplicate detection results id: post_results if: steps.extract.outputs.should_continue == 'true' && steps.fetch_similar.outputs.has_similar == 'true' - uses: actions/github-script@v8 + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 env: AI_RESPONSE: ${{ steps.ai_detection.outputs.response }} SIMILAR_ISSUES: ${{ steps.fetch_similar.outputs.similar_issues }} diff --git a/.github/workflows/detect-non-english-issues.yml b/.github/workflows/detect-non-english-issues.yml index d18726c8c793ba..ec569f63ca317d 100644 --- a/.github/workflows/detect-non-english-issues.yml +++ b/.github/workflows/detect-non-english-issues.yml @@ -16,7 +16,7 @@ jobs: steps: - name: Check issue language id: detect_language - uses: actions/github-script@v8 + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 env: ISSUE_NUMBER: ${{ github.event.issue.number }} ISSUE_TITLE: ${{ github.event.issue.title }} @@ -57,7 +57,7 @@ jobs: - name: Detect language using AI id: ai_language_detection if: steps.detect_language.outputs.should_continue == 'true' - uses: actions/ai-inference@v2.0.1 + uses: actions/ai-inference@a1c11829223a786afe3b5663db904a3aa1eac3a2 # v2.0.1 with: model: openai/gpt-4o-mini system-prompt: | @@ -90,7 +90,7 @@ jobs: - name: Process non-English issues if: steps.detect_language.outputs.should_continue == 'true' - uses: actions/github-script@v8 + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 env: AI_RESPONSE: ${{ steps.ai_language_detection.outputs.response }} ISSUE_NUMBER: ${{ steps.detect_language.outputs.issue_number }} diff --git a/.github/workflows/lock.yml b/.github/workflows/lock.yml index fb5deb2958f1bc..daaa737471370f 100644 --- a/.github/workflows/lock.yml +++ b/.github/workflows/lock.yml @@ -10,7 +10,7 @@ jobs: if: github.repository_owner == 'home-assistant' runs-on: ubuntu-latest steps: - - uses: dessant/lock-threads@v5.0.1 + - uses: dessant/lock-threads@1bf7ec25051fe7c00bdd17e6a7cf3d7bfb7dc771 # v5.0.1 with: github-token: ${{ github.token }} issue-inactive-days: "30" diff --git a/.github/workflows/restrict-task-creation.yml b/.github/workflows/restrict-task-creation.yml index beb14a80bed6a3..1b78cae3e0fc14 100644 --- a/.github/workflows/restrict-task-creation.yml +++ b/.github/workflows/restrict-task-creation.yml @@ -12,7 +12,7 @@ jobs: if: github.event.issue.type.name == 'Task' steps: - name: Check if user is authorized - uses: actions/github-script@v8 + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 with: script: | const issueAuthor = context.payload.issue.user.login; diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml index f0e2572fa54140..86be8cd4da5008 100644 --- a/.github/workflows/stale.yml +++ b/.github/workflows/stale.yml @@ -17,7 +17,7 @@ jobs: # - No PRs marked as no-stale # - No issues (-1) - name: 60 days stale PRs policy - uses: actions/stale@v10.0.0 + uses: actions/stale@3a9db7e6a41a89f618792c92c0e97cc736e1b13f # v10.0.0 with: repo-token: ${{ secrets.GITHUB_TOKEN }} days-before-stale: 60 @@ -57,7 +57,7 @@ jobs: # - No issues marked as no-stale or help-wanted # - No PRs (-1) - name: 90 days stale issues - uses: actions/stale@v10.0.0 + uses: actions/stale@3a9db7e6a41a89f618792c92c0e97cc736e1b13f # v10.0.0 with: repo-token: ${{ steps.token.outputs.token }} days-before-stale: 90 @@ -87,7 +87,7 @@ jobs: # - No Issues marked as no-stale or help-wanted # - No PRs (-1) - name: Needs more information stale issues policy - uses: actions/stale@v10.0.0 + uses: actions/stale@3a9db7e6a41a89f618792c92c0e97cc736e1b13f # v10.0.0 with: repo-token: ${{ steps.token.outputs.token }} only-labels: "needs-more-information" diff --git a/.github/workflows/translations.yml b/.github/workflows/translations.yml index e0ffe2933e009a..fb4cb43e7c0433 100644 --- a/.github/workflows/translations.yml +++ b/.github/workflows/translations.yml @@ -19,10 +19,10 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout the repository - uses: actions/checkout@v5.0.0 + uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 - name: Set up Python ${{ env.DEFAULT_PYTHON }} - uses: actions/setup-python@v6.0.0 + uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c # v6.0.0 with: python-version: ${{ env.DEFAULT_PYTHON }} diff --git a/.github/workflows/wheels.yml b/.github/workflows/wheels.yml index 7ac7c239816412..0292677ab9352f 100644 --- a/.github/workflows/wheels.yml +++ b/.github/workflows/wheels.yml @@ -32,11 +32,11 @@ jobs: architectures: ${{ steps.info.outputs.architectures }} steps: - name: Checkout the repository - uses: actions/checkout@v5.0.0 + uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 - name: Set up Python ${{ env.DEFAULT_PYTHON }} id: python - uses: actions/setup-python@v6.0.0 + uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c # v6.0.0 with: python-version: ${{ env.DEFAULT_PYTHON }} check-latest: true @@ -91,7 +91,7 @@ jobs: ) > build_constraints.txt - name: Upload env_file - uses: actions/upload-artifact@v4.6.2 + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: name: env_file path: ./.env_file @@ -99,14 +99,14 @@ jobs: overwrite: true - name: Upload build_constraints - uses: actions/upload-artifact@v4.6.2 + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: name: build_constraints path: ./build_constraints.txt overwrite: true - name: Upload requirements_diff - uses: actions/upload-artifact@v4.6.2 + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: name: requirements_diff path: ./requirements_diff.txt @@ -118,7 +118,7 @@ jobs: python -m script.gen_requirements_all ci - name: Upload requirements_all_wheels - uses: actions/upload-artifact@v4.6.2 + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: name: requirements_all_wheels path: ./requirements_all_wheels_*.txt @@ -135,20 +135,20 @@ jobs: arch: ${{ fromJson(needs.init.outputs.architectures) }} steps: - name: Checkout the repository - uses: actions/checkout@v5.0.0 + uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 - name: Download env_file - uses: actions/download-artifact@v5.0.0 + uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0 with: name: env_file - name: Download build_constraints - uses: actions/download-artifact@v5.0.0 + uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0 with: name: build_constraints - name: Download requirements_diff - uses: actions/download-artifact@v5.0.0 + uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0 with: name: requirements_diff @@ -159,7 +159,7 @@ jobs: sed -i "/uv/d" requirements_diff.txt - name: Build wheels - uses: home-assistant/wheels@2025.07.0 + uses: home-assistant/wheels@bf4ddde339dde61ba98ccb4330517936bed6d2f8 # 2025.07.0 with: abi: ${{ matrix.abi }} tag: musllinux_1_2 @@ -184,25 +184,25 @@ jobs: arch: ${{ fromJson(needs.init.outputs.architectures) }} steps: - name: Checkout the repository - uses: actions/checkout@v5.0.0 + uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 - name: Download env_file - uses: actions/download-artifact@v5.0.0 + uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0 with: name: env_file - name: Download build_constraints - uses: actions/download-artifact@v5.0.0 + uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0 with: name: build_constraints - name: Download requirements_diff - uses: actions/download-artifact@v5.0.0 + uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0 with: name: requirements_diff - name: Download requirements_all_wheels - uses: actions/download-artifact@v5.0.0 + uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0 with: name: requirements_all_wheels @@ -219,7 +219,7 @@ jobs: sed -i "/uv/d" requirements_diff.txt - name: Build wheels - uses: home-assistant/wheels@2025.07.0 + uses: home-assistant/wheels@bf4ddde339dde61ba98ccb4330517936bed6d2f8 # 2025.07.0 with: abi: ${{ matrix.abi }} tag: musllinux_1_2 From b93072865bd9efd8df61c361cdf7cb1aab7e7ad8 Mon Sep 17 00:00:00 2001 From: Artur Pragacz <49985303+arturpragacz@users.noreply.github.com> Date: Fri, 12 Sep 2025 19:21:41 +0200 Subject: [PATCH 20/30] Clean up unused partial action response in intent helper (#151908) --- homeassistant/helpers/intent.py | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/homeassistant/helpers/intent.py b/homeassistant/helpers/intent.py index a412d475acff88..5b21c12d755330 100644 --- a/homeassistant/helpers/intent.py +++ b/homeassistant/helpers/intent.py @@ -33,6 +33,7 @@ entity_registry, floor_registry, ) +from .deprecation import EnumWithDeprecatedMembers from .typing import VolSchemaType _LOGGER = logging.getLogger(__name__) @@ -1316,14 +1317,23 @@ def create_response(self) -> IntentResponse: return IntentResponse(language=self.language, intent=self) -class IntentResponseType(Enum): +class IntentResponseType( + Enum, + metaclass=EnumWithDeprecatedMembers, + deprecated={ + "PARTIAL_ACTION_DONE": ( + "IntentResponseType.ACTION_DONE or IntentResponseType.ERROR", + "2026.3.0", + ), + }, +): """Type of the intent response.""" ACTION_DONE = "action_done" """Intent caused an action to occur""" PARTIAL_ACTION_DONE = "partial_action_done" - """Intent caused an action, but it could only be partially done""" + """Deprecated. Intent caused an action, but it could only be partially done""" QUERY_ANSWER = "query_answer" """Response is an answer to a query""" From 6d231c2c99350ee805cc502540fcf89f72df50a2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Hjelseth=20H=C3=B8yer?= Date: Fri, 12 Sep 2025 19:23:34 +0200 Subject: [PATCH 21/30] Tibber 15min prices (#151881) Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- homeassistant/components/tibber/manifest.json | 2 +- homeassistant/components/tibber/sensor.py | 7 +++---- homeassistant/components/tibber/services.py | 1 - requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/tibber/test_services.py | 12 ------------ 6 files changed, 6 insertions(+), 20 deletions(-) diff --git a/homeassistant/components/tibber/manifest.json b/homeassistant/components/tibber/manifest.json index ea1701b77a4019..9c474e628737d5 100644 --- a/homeassistant/components/tibber/manifest.json +++ b/homeassistant/components/tibber/manifest.json @@ -7,5 +7,5 @@ "documentation": "https://www.home-assistant.io/integrations/tibber", "iot_class": "cloud_polling", "loggers": ["tibber"], - "requirements": ["pyTibber==0.31.7"] + "requirements": ["pyTibber==0.32.0"] } diff --git a/homeassistant/components/tibber/sensor.py b/homeassistant/components/tibber/sensor.py index 1c56d5b2ce6eb5..b087ef406a19fe 100644 --- a/homeassistant/components/tibber/sensor.py +++ b/homeassistant/components/tibber/sensor.py @@ -377,7 +377,6 @@ def __init__(self, tibber_home: tibber.TibberHome) -> None: "app_nickname": None, "grid_company": None, "estimated_annual_consumption": None, - "price_level": None, "max_price": None, "avg_price": None, "min_price": None, @@ -405,16 +404,16 @@ async def async_update(self) -> None: await self._fetch_data() elif ( - self._tibber_home.current_price_total + self._tibber_home.price_total and self._last_updated and self._last_updated.hour == now.hour + and now - self._last_updated < timedelta(minutes=15) and self._tibber_home.last_data_timestamp ): return res = self._tibber_home.current_price_data() - self._attr_native_value, price_level, self._last_updated, price_rank = res - self._attr_extra_state_attributes["price_level"] = price_level + self._attr_native_value, self._last_updated, price_rank = res self._attr_extra_state_attributes["intraday_price_ranking"] = price_rank attrs = self._tibber_home.current_attributes() diff --git a/homeassistant/components/tibber/services.py b/homeassistant/components/tibber/services.py index 938e96b9917184..d5bb3fd4854b92 100644 --- a/homeassistant/components/tibber/services.py +++ b/homeassistant/components/tibber/services.py @@ -50,7 +50,6 @@ async def __get_prices(call: ServiceCall) -> ServiceResponse: { "start_time": starts_at, "price": price, - "level": tibber_home.price_level.get(starts_at), } for starts_at, price in tibber_home.price_total.items() ] diff --git a/requirements_all.txt b/requirements_all.txt index 5f84d610b12b72..862802fc03d5c3 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1822,7 +1822,7 @@ pyRFXtrx==0.31.1 pySDCP==1 # homeassistant.components.tibber -pyTibber==0.31.7 +pyTibber==0.32.0 # homeassistant.components.dlink pyW215==0.8.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 1f608db5cd2a09..81c786b2265e37 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1533,7 +1533,7 @@ pyHomee==1.3.8 pyRFXtrx==0.31.1 # homeassistant.components.tibber -pyTibber==0.31.7 +pyTibber==0.32.0 # homeassistant.components.dlink pyW215==0.8.0 diff --git a/tests/components/tibber/test_services.py b/tests/components/tibber/test_services.py index dc6f5d2789df2c..9c9fb86f91717f 100644 --- a/tests/components/tibber/test_services.py +++ b/tests/components/tibber/test_services.py @@ -88,24 +88,20 @@ async def test_get_prices( { "start_time": START_TIME.isoformat(), "price": 0.36914, - "level": "VERY_EXPENSIVE", }, { "start_time": (START_TIME + dt.timedelta(hours=1)).isoformat(), "price": 0.36914, - "level": "VERY_EXPENSIVE", }, ], "second_home": [ { "start_time": START_TIME.isoformat(), "price": 0.36914, - "level": "VERY_EXPENSIVE", }, { "start_time": (START_TIME + dt.timedelta(hours=1)).isoformat(), "price": 0.36914, - "level": "VERY_EXPENSIVE", }, ], } @@ -138,24 +134,20 @@ async def test_get_prices_start_tomorrow( { "start_time": tomorrow.isoformat(), "price": 0.46914, - "level": "VERY_EXPENSIVE", }, { "start_time": (tomorrow + dt.timedelta(hours=1)).isoformat(), "price": 0.46914, - "level": "VERY_EXPENSIVE", }, ], "second_home": [ { "start_time": tomorrow.isoformat(), "price": 0.46914, - "level": "VERY_EXPENSIVE", }, { "start_time": (tomorrow + dt.timedelta(hours=1)).isoformat(), "price": 0.46914, - "level": "VERY_EXPENSIVE", }, ], } @@ -197,24 +189,20 @@ async def test_get_prices_with_timezones( { "start_time": START_TIME.isoformat(), "price": 0.36914, - "level": "VERY_EXPENSIVE", }, { "start_time": (START_TIME + dt.timedelta(hours=1)).isoformat(), "price": 0.36914, - "level": "VERY_EXPENSIVE", }, ], "second_home": [ { "start_time": START_TIME.isoformat(), "price": 0.36914, - "level": "VERY_EXPENSIVE", }, { "start_time": (START_TIME + dt.timedelta(hours=1)).isoformat(), "price": 0.36914, - "level": "VERY_EXPENSIVE", }, ], } From 71bf5e14cc9fc4de89cf38648fc3d7f2d16a2dda Mon Sep 17 00:00:00 2001 From: AdrianEddy Date: Fri, 12 Sep 2025 19:24:59 +0200 Subject: [PATCH 22/30] Add On/Off switch for DiscreteHeatingSystem in Overkiz (#151778) Co-authored-by: Mick Vleeshouwer --- homeassistant/components/overkiz/const.py | 1 + homeassistant/components/overkiz/switch.py | 9 +++++++++ 2 files changed, 10 insertions(+) diff --git a/homeassistant/components/overkiz/const.py b/homeassistant/components/overkiz/const.py index 7f5f4ad85bd40b..99b7d48dcca779 100644 --- a/homeassistant/components/overkiz/const.py +++ b/homeassistant/components/overkiz/const.py @@ -100,6 +100,7 @@ UIWidget.ATLANTIC_PASS_APC_HEATING_AND_COOLING_ZONE: Platform.CLIMATE, # widgetName, uiClass is HeatingSystem (not supported) UIWidget.ATLANTIC_PASS_APC_HEATING_ZONE: Platform.CLIMATE, # widgetName, uiClass is HeatingSystem (not supported) UIWidget.ATLANTIC_PASS_APC_ZONE_CONTROL: Platform.CLIMATE, # widgetName, uiClass is HeatingSystem (not supported) + UIWidget.DISCRETE_EXTERIOR_HEATING: Platform.SWITCH, # widgetName, uiClass is ExteriorHeatingSystem (not supported) UIWidget.DOMESTIC_HOT_WATER_PRODUCTION: Platform.WATER_HEATER, # widgetName, uiClass is WaterHeatingSystem (not supported) UIWidget.DOMESTIC_HOT_WATER_TANK: Platform.SWITCH, # widgetName, uiClass is WaterHeatingSystem (not supported) UIWidget.EVO_HOME_CONTROLLER: Platform.CLIMATE, # widgetName, uiClass is EvoHome (not supported) diff --git a/homeassistant/components/overkiz/switch.py b/homeassistant/components/overkiz/switch.py index d14b2792947bcc..9260f9800a11f2 100644 --- a/homeassistant/components/overkiz/switch.py +++ b/homeassistant/components/overkiz/switch.py @@ -100,6 +100,15 @@ class OverkizSwitchDescription(SwitchEntityDescription): ), entity_category=EntityCategory.CONFIG, ), + OverkizSwitchDescription( + key=UIWidget.DISCRETE_EXTERIOR_HEATING, + turn_on=OverkizCommand.ON, + turn_off=OverkizCommand.OFF, + icon="mdi:radiator", + is_on=lambda select_state: ( + select_state(OverkizState.CORE_ON_OFF) == OverkizCommandParam.ON + ), + ), ] SUPPORTED_DEVICES = { From bfe1dd65b3640c2dbbdadd13839a1c11e32580a1 Mon Sep 17 00:00:00 2001 From: Nc Hodges <86037210+Hodnc@users.noreply.github.com> Date: Sat, 13 Sep 2025 03:57:58 +1000 Subject: [PATCH 23/30] Add device and state class to Temp and Voltage entities. (#145613) Co-authored-by: Joost Lekkerkerker Co-authored-by: J. Nick Koston --- homeassistant/components/elkm1/sensor.py | 26 +++++++++++++++++++++++- 1 file changed, 25 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/elkm1/sensor.py b/homeassistant/components/elkm1/sensor.py index 328672edbeda3c..5a3f476a65da89 100644 --- a/homeassistant/components/elkm1/sensor.py +++ b/homeassistant/components/elkm1/sensor.py @@ -14,7 +14,11 @@ from elkm1_lib.zones import Zone import voluptuous as vol -from homeassistant.components.sensor import SensorEntity +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorStateClass, +) from homeassistant.const import EntityCategory, UnitOfElectricPotential from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError @@ -32,6 +36,16 @@ SERVICE_SENSOR_ZONE_TRIGGER = "sensor_zone_trigger" UNDEFINED_TEMPERATURE = -40 +_DEVICE_CLASS_MAP: dict[ZoneType, SensorDeviceClass] = { + ZoneType.TEMPERATURE: SensorDeviceClass.TEMPERATURE, + ZoneType.ANALOG_ZONE: SensorDeviceClass.VOLTAGE, +} + +_STATE_CLASS_MAP: dict[ZoneType, SensorStateClass] = { + ZoneType.TEMPERATURE: SensorStateClass.MEASUREMENT, + ZoneType.ANALOG_ZONE: SensorStateClass.MEASUREMENT, +} + ELK_SET_COUNTER_SERVICE_SCHEMA: VolDictType = { vol.Required(ATTR_VALUE): vol.All(vol.Coerce(int), vol.Range(0, 65535)) } @@ -248,6 +262,16 @@ def temperature_unit(self) -> str | None: return self._temperature_unit return None + @property + def device_class(self) -> SensorDeviceClass | None: + """Return the device class of the sensor.""" + return _DEVICE_CLASS_MAP.get(self._element.definition) + + @property + def state_class(self) -> SensorStateClass | None: + """Return the state class of the sensor.""" + return _STATE_CLASS_MAP.get(self._element.definition) + @property def native_unit_of_measurement(self) -> str | None: """Return the unit of measurement.""" From 3de701a9abfd421b06bfeccd1657c615837575c9 Mon Sep 17 00:00:00 2001 From: Michael Hansen Date: Fri, 12 Sep 2025 13:40:16 -0500 Subject: [PATCH 24/30] Acknowledge if targets in same area (#150655) Co-authored-by: Artur Pragacz <49985303+arturpragacz@users.noreply.github.com> --- .../assist_pipeline/acknowledge.mp3 | Bin 0 -> 50991 bytes .../components/assist_pipeline/const.py | 4 + .../components/assist_pipeline/pipeline.py | 95 +++++- .../assist_pipeline/snapshots/test_init.ambr | 4 + .../snapshots/test_pipeline.ambr | 3 + .../snapshots/test_websocket.ambr | 4 + .../assist_pipeline/test_pipeline.py | 309 +++++++++++++++++- 7 files changed, 405 insertions(+), 14 deletions(-) create mode 100644 homeassistant/components/assist_pipeline/acknowledge.mp3 diff --git a/homeassistant/components/assist_pipeline/acknowledge.mp3 b/homeassistant/components/assist_pipeline/acknowledge.mp3 new file mode 100644 index 0000000000000000000000000000000000000000..1709ff20bc2b7c49bb8335fbc5a444b7c1f3d67e GIT binary patch literal 50991 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 Any: vol.Required("wake_word_entity"): vol.Any(str, None), vol.Required("wake_word_id"): vol.Any(str, None), vol.Optional("prefer_local_intents"): bool, + vol.Optional("acknowledge_media_id"): str, } STORED_PIPELINE_RUNS = 10 @@ -1066,8 +1073,11 @@ async def recognize_intent( intent_input: str, conversation_id: str, conversation_extra_system_prompt: str | None, - ) -> str: - """Run intent recognition portion of pipeline. Returns text to speak.""" + ) -> tuple[str, bool]: + """Run intent recognition portion of pipeline. + + Returns (speech, all_targets_in_satellite_area). + """ if self.intent_agent is None or self._conversation_data is None: raise RuntimeError("Recognize intent was not prepared") @@ -1116,6 +1126,7 @@ async def recognize_intent( agent_id = self.intent_agent.id processed_locally = agent_id == conversation.HOME_ASSISTANT_AGENT + all_targets_in_satellite_area = False intent_response: intent.IntentResponse | None = None if not processed_locally and not self._intent_agent_only: # Sentence triggers override conversation agent @@ -1290,6 +1301,17 @@ async def tts_input_stream_generator() -> AsyncGenerator[str]: if tts_input_stream and self._streamed_response_text: tts_input_stream.put_nowait(None) + if agent_id == conversation.HOME_ASSISTANT_AGENT: + # Check if all targeted entities were in the same area as + # the satellite device. + # If so, the satellite should respond with an acknowledge beep + # instead of a full response. + all_targets_in_satellite_area = ( + self._get_all_targets_in_satellite_area( + conversation_result.response, self._device_id + ) + ) + except Exception as src_error: _LOGGER.exception("Unexpected error during intent recognition") raise IntentRecognitionError( @@ -1312,7 +1334,45 @@ async def tts_input_stream_generator() -> AsyncGenerator[str]: if conversation_result.continue_conversation: self._conversation_data.continue_conversation_agent = agent_id - return speech + return (speech, all_targets_in_satellite_area) + + def _get_all_targets_in_satellite_area( + self, intent_response: intent.IntentResponse, device_id: str | None + ) -> bool: + """Return true if all targeted entities were in the same area as the device.""" + if ( + (intent_response.response_type != intent.IntentResponseType.ACTION_DONE) + or (not intent_response.matched_states) + or (not device_id) + ): + return False + + device_registry = dr.async_get(self.hass) + + if (not (device := device_registry.async_get(device_id))) or ( + not device.area_id + ): + return False + + entity_registry = er.async_get(self.hass) + for state in intent_response.matched_states: + entity = entity_registry.async_get(state.entity_id) + if not entity: + return False + + if (entity_area_id := entity.area_id) is None: + if (entity.device_id is None) or ( + (entity_device := device_registry.async_get(entity.device_id)) + is None + ): + return False + + entity_area_id = entity_device.area_id + + if entity_area_id != device.area_id: + return False + + return True async def prepare_text_to_speech(self) -> None: """Prepare text-to-speech.""" @@ -1350,7 +1410,9 @@ async def prepare_text_to_speech(self) -> None: ), ) from err - async def text_to_speech(self, tts_input: str) -> None: + async def text_to_speech( + self, tts_input: str, override_media_path: Path | None = None + ) -> None: """Run text-to-speech portion of pipeline.""" assert self.tts_stream is not None @@ -1362,11 +1424,14 @@ async def text_to_speech(self, tts_input: str) -> None: "language": self.pipeline.tts_language, "voice": self.pipeline.tts_voice, "tts_input": tts_input, + "acknowledge_override": override_media_path is not None, }, ) ) - if not self._streamed_response_text: + if override_media_path: + self.tts_stream.async_override_result(override_media_path) + elif not self._streamed_response_text: self.tts_stream.async_set_message(tts_input) tts_output = { @@ -1664,16 +1729,20 @@ async def buffer_then_audio_stream() -> AsyncGenerator[ if self.run.end_stage != PipelineStage.STT: tts_input = self.tts_input + all_targets_in_satellite_area = False if current_stage == PipelineStage.INTENT: # intent-recognition assert intent_input is not None - tts_input = await self.run.recognize_intent( + ( + tts_input, + all_targets_in_satellite_area, + ) = await self.run.recognize_intent( intent_input, self.session.conversation_id, self.conversation_extra_system_prompt, ) - if tts_input.strip(): + if all_targets_in_satellite_area or tts_input.strip(): current_stage = PipelineStage.TTS else: # Skip TTS @@ -1682,8 +1751,14 @@ async def buffer_then_audio_stream() -> AsyncGenerator[ if self.run.end_stage != PipelineStage.INTENT: # text-to-speech if current_stage == PipelineStage.TTS: - assert tts_input is not None - await self.run.text_to_speech(tts_input) + if all_targets_in_satellite_area: + # Use acknowledge media instead of full response + await self.run.text_to_speech( + tts_input or "", override_media_path=ACKNOWLEDGE_PATH + ) + else: + assert tts_input is not None + await self.run.text_to_speech(tts_input) except PipelineError as err: self.run.process_event( diff --git a/tests/components/assist_pipeline/snapshots/test_init.ambr b/tests/components/assist_pipeline/snapshots/test_init.ambr index 56ca8bde0ba350..5e77b7e9291407 100644 --- a/tests/components/assist_pipeline/snapshots/test_init.ambr +++ b/tests/components/assist_pipeline/snapshots/test_init.ambr @@ -76,6 +76,7 @@ }), dict({ 'data': dict({ + 'acknowledge_override': False, 'engine': 'tts.test', 'language': 'en_US', 'tts_input': "Sorry, I couldn't understand that", @@ -177,6 +178,7 @@ }), dict({ 'data': dict({ + 'acknowledge_override': False, 'engine': 'test', 'language': 'en-US', 'tts_input': "Sorry, I couldn't understand that", @@ -278,6 +280,7 @@ }), dict({ 'data': dict({ + 'acknowledge_override': False, 'engine': 'test', 'language': 'en-US', 'tts_input': "Sorry, I couldn't understand that", @@ -403,6 +406,7 @@ }), dict({ 'data': dict({ + 'acknowledge_override': False, 'engine': 'tts.test', 'language': 'en_US', 'tts_input': "Sorry, I couldn't understand that", diff --git a/tests/components/assist_pipeline/snapshots/test_pipeline.ambr b/tests/components/assist_pipeline/snapshots/test_pipeline.ambr index 7a51eddf8d6735..e92f3aec3fb484 100644 --- a/tests/components/assist_pipeline/snapshots/test_pipeline.ambr +++ b/tests/components/assist_pipeline/snapshots/test_pipeline.ambr @@ -131,6 +131,7 @@ }), dict({ 'data': dict({ + 'acknowledge_override': False, 'engine': 'tts.test', 'language': 'en_US', 'tts_input': 'hello, how are you?', @@ -365,6 +366,7 @@ }), dict({ 'data': dict({ + 'acknowledge_override': False, 'engine': 'tts.test', 'language': 'en_US', 'tts_input': "hello, how are you? I'm doing well, thank you. What about you?!", @@ -595,6 +597,7 @@ }), dict({ 'data': dict({ + 'acknowledge_override': False, 'engine': 'tts.test', 'language': 'en_US', 'tts_input': "I'm doing well, thank you.", diff --git a/tests/components/assist_pipeline/snapshots/test_websocket.ambr b/tests/components/assist_pipeline/snapshots/test_websocket.ambr index 5e0d915a77e19e..5b5ed44e24d9f1 100644 --- a/tests/components/assist_pipeline/snapshots/test_websocket.ambr +++ b/tests/components/assist_pipeline/snapshots/test_websocket.ambr @@ -73,6 +73,7 @@ # --- # name: test_audio_pipeline.5 dict({ + 'acknowledge_override': False, 'engine': 'tts.test', 'language': 'en_US', 'tts_input': "Sorry, I couldn't understand that", @@ -166,6 +167,7 @@ # --- # name: test_audio_pipeline_debug.5 dict({ + 'acknowledge_override': False, 'engine': 'tts.test', 'language': 'en_US', 'tts_input': "Sorry, I couldn't understand that", @@ -271,6 +273,7 @@ # --- # name: test_audio_pipeline_with_enhancements.5 dict({ + 'acknowledge_override': False, 'engine': 'tts.test', 'language': 'en_US', 'tts_input': "Sorry, I couldn't understand that", @@ -386,6 +389,7 @@ # --- # name: test_audio_pipeline_with_wake_word_no_timeout.7 dict({ + 'acknowledge_override': False, 'engine': 'tts.test', 'language': 'en_US', 'tts_input': "Sorry, I couldn't understand that", diff --git a/tests/components/assist_pipeline/test_pipeline.py b/tests/components/assist_pipeline/test_pipeline.py index 75234122368949..fe82f693fde1a6 100644 --- a/tests/components/assist_pipeline/test_pipeline.py +++ b/tests/components/assist_pipeline/test_pipeline.py @@ -16,13 +16,14 @@ stt, tts, ) -from homeassistant.components.assist_pipeline.const import DOMAIN +from homeassistant.components.assist_pipeline.const import ACKNOWLEDGE_PATH, DOMAIN from homeassistant.components.assist_pipeline.pipeline import ( STORAGE_KEY, STORAGE_VERSION, STORAGE_VERSION_MINOR, Pipeline, PipelineData, + PipelineEventType, PipelineStorageCollection, PipelineStore, _async_local_fallback_intent_filter, @@ -31,9 +32,16 @@ async_get_pipelines, async_update_pipeline, ) -from homeassistant.const import MATCH_ALL +from homeassistant.const import ATTR_FRIENDLY_NAME, MATCH_ALL from homeassistant.core import Context, HomeAssistant -from homeassistant.helpers import chat_session, intent, llm +from homeassistant.helpers import ( + area_registry as ar, + chat_session, + device_registry as dr, + entity_registry as er, + intent, + llm, +) from homeassistant.setup import async_setup_component from . import MANY_LANGUAGES, process_events @@ -46,7 +54,7 @@ make_10ms_chunk, ) -from tests.common import flush_store +from tests.common import MockConfigEntry, async_mock_service, flush_store from tests.typing import ClientSessionGenerator, WebSocketGenerator @@ -1787,3 +1795,296 @@ async def stream_llm_response(): assert "".join(received_tts) == chunk_text assert process_events(events) == snapshot + + +async def test_acknowledge( + hass: HomeAssistant, + init_components, + pipeline_data: assist_pipeline.pipeline.PipelineData, + mock_chat_session: chat_session.ChatSession, + entity_registry: er.EntityRegistry, + area_registry: ar.AreaRegistry, + device_registry: dr.DeviceRegistry, +) -> None: + """Test that acknowledge sound is played when targets are in the same area.""" + area_1 = area_registry.async_get_or_create("area_1") + + light_1 = entity_registry.async_get_or_create("light", "demo", "1234") + hass.states.async_set(light_1.entity_id, "off", {ATTR_FRIENDLY_NAME: "light 1"}) + light_1 = entity_registry.async_update_entity(light_1.entity_id, area_id=area_1.id) + + light_2 = entity_registry.async_get_or_create("light", "demo", "5678") + hass.states.async_set(light_2.entity_id, "off", {ATTR_FRIENDLY_NAME: "light 2"}) + light_2 = entity_registry.async_update_entity(light_2.entity_id, area_id=area_1.id) + + entry = MockConfigEntry() + entry.add_to_hass(hass) + satellite = device_registry.async_get_or_create( + config_entry_id=entry.entry_id, + connections=set(), + identifiers={("demo", "id-1234")}, + ) + device_registry.async_update_device(satellite.id, area_id=area_1.id) + + events: list[assist_pipeline.PipelineEvent] = [] + turn_on = async_mock_service(hass, "light", "turn_on") + + pipeline_store = pipeline_data.pipeline_store + pipeline_id = pipeline_store.async_get_preferred_item() + pipeline = assist_pipeline.pipeline.async_get_pipeline(hass, pipeline_id) + + async def _run(text: str) -> None: + pipeline_input = assist_pipeline.pipeline.PipelineInput( + intent_input=text, + session=mock_chat_session, + device_id=satellite.id, + run=assist_pipeline.pipeline.PipelineRun( + hass, + context=Context(), + pipeline=pipeline, + start_stage=assist_pipeline.PipelineStage.INTENT, + end_stage=assist_pipeline.PipelineStage.TTS, + event_callback=events.append, + ), + ) + await pipeline_input.validate() + await pipeline_input.execute() + + with patch( + "homeassistant.components.assist_pipeline.PipelineRun.text_to_speech" + ) as text_to_speech: + + def _reset() -> None: + events.clear() + text_to_speech.reset_mock() + turn_on.clear() + + # 1. All targets in same area + await _run("turn on the lights") + + # Acknowledgment sound should be played (same area) + text_to_speech.assert_called_once() + assert ( + text_to_speech.call_args.kwargs["override_media_path"] == ACKNOWLEDGE_PATH + ) + assert len(turn_on) == 2 + + # 2. One light in a different area + area_2 = area_registry.async_get_or_create("area_2") + light_2 = entity_registry.async_update_entity( + light_2.entity_id, area_id=area_2.id + ) + + _reset() + await _run("turn on light 2") + + # Acknowledgment sound should be not played (different area) + text_to_speech.assert_called_once() + assert text_to_speech.call_args.kwargs.get("override_media_path") is None + assert len(turn_on) == 1 + + # Restore + light_2 = entity_registry.async_update_entity( + light_2.entity_id, area_id=area_1.id + ) + + # 3. Remove satellite device area + device_registry.async_update_device(satellite.id, area_id=None) + + _reset() + await _run("turn on light 1") + + # Acknowledgment sound should be not played (no satellite area) + text_to_speech.assert_called_once() + assert text_to_speech.call_args.kwargs.get("override_media_path") is None + assert len(turn_on) == 1 + + # Restore + device_registry.async_update_device(satellite.id, area_id=area_1.id) + + # 4. Check device area instead of entity area + light_device = device_registry.async_get_or_create( + config_entry_id=entry.entry_id, + connections=set(), + identifiers={("demo", "id-5678")}, + ) + device_registry.async_update_device(light_device.id, area_id=area_1.id) + light_2 = entity_registry.async_update_entity( + light_2.entity_id, area_id=None, device_id=light_device.id + ) + + _reset() + await _run("turn on the lights") + + # Acknowledgment sound should be played (same area) + text_to_speech.assert_called_once() + assert ( + text_to_speech.call_args.kwargs["override_media_path"] == ACKNOWLEDGE_PATH + ) + assert len(turn_on) == 2 + + # 5. Move device to different area + device_registry.async_update_device(light_device.id, area_id=area_2.id) + + _reset() + await _run("turn on light 2") + + # Acknowledgment sound should be not played (different device area) + text_to_speech.assert_called_once() + assert text_to_speech.call_args.kwargs.get("override_media_path") is None + assert len(turn_on) == 1 + + # 6. No device or area + light_2 = entity_registry.async_update_entity( + light_2.entity_id, area_id=None, device_id=None + ) + + _reset() + await _run("turn on light 2") + + # Acknowledgment sound should be not played (no area) + text_to_speech.assert_called_once() + assert text_to_speech.call_args.kwargs.get("override_media_path") is None + assert len(turn_on) == 1 + + # 7. Not in entity registry + hass.states.async_set("light.light_3", "off", {ATTR_FRIENDLY_NAME: "light 3"}) + + _reset() + await _run("turn on light 3") + + # Acknowledgment sound should be not played (not in entity registry) + text_to_speech.assert_called_once() + assert text_to_speech.call_args.kwargs.get("override_media_path") is None + assert len(turn_on) == 1 + + # Check TTS event + events.clear() + await _run("turn on light 1") + + has_acknowledge_override: bool | None = None + for event in events: + if event.type == PipelineEventType.TTS_START: + assert event.data + has_acknowledge_override = event.data["acknowledge_override"] + break + + assert has_acknowledge_override + + +async def test_acknowledge_other_agents( + hass: HomeAssistant, + init_components, + pipeline_data: assist_pipeline.pipeline.PipelineData, + mock_chat_session: chat_session.ChatSession, + entity_registry: er.EntityRegistry, + area_registry: ar.AreaRegistry, + device_registry: dr.DeviceRegistry, +) -> None: + """Test that acknowledge sound is only played when intents are processed locally for other agents.""" + area_1 = area_registry.async_get_or_create("area_1") + + light_1 = entity_registry.async_get_or_create("light", "demo", "1234") + hass.states.async_set(light_1.entity_id, "off", {ATTR_FRIENDLY_NAME: "light 1"}) + light_1 = entity_registry.async_update_entity(light_1.entity_id, area_id=area_1.id) + + light_2 = entity_registry.async_get_or_create("light", "demo", "5678") + hass.states.async_set(light_2.entity_id, "off", {ATTR_FRIENDLY_NAME: "light 2"}) + light_2 = entity_registry.async_update_entity(light_2.entity_id, area_id=area_1.id) + + entry = MockConfigEntry() + entry.add_to_hass(hass) + satellite = device_registry.async_get_or_create( + config_entry_id=entry.entry_id, + connections=set(), + identifiers={("demo", "id-1234")}, + ) + device_registry.async_update_device(satellite.id, area_id=area_1.id) + + events: list[assist_pipeline.PipelineEvent] = [] + async_mock_service(hass, "light", "turn_on") + + pipeline_store = pipeline_data.pipeline_store + pipeline = await pipeline_store.async_create_item( + { + "name": "Test 1", + "language": "en-US", + "conversation_engine": "test agent", + "conversation_language": "en-US", + "tts_engine": "test tts", + "tts_language": "en-US", + "tts_voice": "test voice", + "stt_engine": "test stt", + "stt_language": "en-US", + "wake_word_entity": None, + "wake_word_id": None, + "prefer_local_intents": True, + } + ) + + with ( + patch( + "homeassistant.components.assist_pipeline.pipeline.conversation.async_get_agent_info", + return_value=conversation.AgentInfo( + id="test-agent", + name="Test Agent", + supports_streaming=False, + ), + ), + patch( + "homeassistant.components.assist_pipeline.PipelineRun.prepare_text_to_speech" + ), + patch( + "homeassistant.components.assist_pipeline.PipelineRun.text_to_speech" + ) as text_to_speech, + patch( + "homeassistant.components.conversation.async_converse", return_value=None + ) as async_converse, + patch( + "homeassistant.components.assist_pipeline.PipelineRun._get_all_targets_in_satellite_area" + ) as get_all_targets_in_satellite_area, + ): + pipeline_input = assist_pipeline.pipeline.PipelineInput( + intent_input="turn on the lights", + session=mock_chat_session, + device_id=satellite.id, + run=assist_pipeline.pipeline.PipelineRun( + hass, + context=Context(), + pipeline=pipeline, + start_stage=assist_pipeline.PipelineStage.INTENT, + end_stage=assist_pipeline.PipelineStage.TTS, + event_callback=events.append, + ), + ) + await pipeline_input.validate() + await pipeline_input.execute() + + # Processed locally + async_converse.assert_not_called() + + # Not processed locally + text_to_speech.reset_mock() + get_all_targets_in_satellite_area.reset_mock() + + pipeline_input = assist_pipeline.pipeline.PipelineInput( + intent_input="not processed locally", + session=mock_chat_session, + device_id=satellite.id, + run=assist_pipeline.pipeline.PipelineRun( + hass, + context=Context(), + pipeline=pipeline, + start_stage=assist_pipeline.PipelineStage.INTENT, + end_stage=assist_pipeline.PipelineStage.TTS, + event_callback=events.append, + ), + ) + await pipeline_input.validate() + await pipeline_input.execute() + + # The acknowledgment should not have even been checked for because the + # default agent didn't handle the intent. + text_to_speech.assert_not_called() + async_converse.assert_called_once() + get_all_targets_in_satellite_area.assert_not_called() From 124a63d846d1c89570b2af98c55ffa1a1475e67d Mon Sep 17 00:00:00 2001 From: Nathan Spencer Date: Fri, 12 Sep 2025 14:55:50 -0400 Subject: [PATCH 25/30] Add globe light settings for Litter-Robot 4 (#152190) --- .../components/litterrobot/icons.json | 15 +++ .../components/litterrobot/select.py | 93 +++++++++++++------ .../components/litterrobot/strings.json | 16 ++++ tests/components/litterrobot/common.py | 3 +- tests/components/litterrobot/test_select.py | 27 ++++-- 5 files changed, 118 insertions(+), 36 deletions(-) diff --git a/homeassistant/components/litterrobot/icons.json b/homeassistant/components/litterrobot/icons.json index 91d48924ff3317..1ee6b899905f43 100644 --- a/homeassistant/components/litterrobot/icons.json +++ b/homeassistant/components/litterrobot/icons.json @@ -31,6 +31,21 @@ "cycle_delay": { "default": "mdi:timer-outline" }, + "globe_brightness": { + "default": "mdi:lightbulb-question", + "state": { + "low": "mdi:lightbulb-on-30", + "medium": "mdi:lightbulb-on-50", + "high": "mdi:lightbulb-on" + } + }, + "globe_light": { + "state": { + "off": "mdi:lightbulb-off", + "on": "mdi:lightbulb-on", + "auto": "mdi:lightbulb-auto" + } + }, "meal_insert_size": { "default": "mdi:scale" } diff --git a/homeassistant/components/litterrobot/select.py b/homeassistant/components/litterrobot/select.py index be3a9915940065..9ee186006b331c 100644 --- a/homeassistant/components/litterrobot/select.py +++ b/homeassistant/components/litterrobot/select.py @@ -7,7 +7,7 @@ from typing import Any, Generic, TypeVar from pylitterbot import FeederRobot, LitterRobot, LitterRobot4, Robot -from pylitterbot.robot.litterrobot4 import BrightnessLevel +from pylitterbot.robot.litterrobot4 import BrightnessLevel, NightLightMode from homeassistant.components.select import SelectEntity, SelectEntityDescription from homeassistant.const import EntityCategory, UnitOfTime @@ -32,35 +32,73 @@ class RobotSelectEntityDescription( select_fn: Callable[[_WhiskerEntityT, str], Coroutine[Any, Any, bool]] -ROBOT_SELECT_MAP: dict[type[Robot], RobotSelectEntityDescription] = { - LitterRobot: RobotSelectEntityDescription[LitterRobot, int]( # type: ignore[type-abstract] # only used for isinstance check - key="cycle_delay", - translation_key="cycle_delay", - unit_of_measurement=UnitOfTime.MINUTES, - current_fn=lambda robot: robot.clean_cycle_wait_time_minutes, - options_fn=lambda robot: robot.VALID_WAIT_TIMES, - select_fn=lambda robot, opt: robot.set_wait_time(int(opt)), +ROBOT_SELECT_MAP: dict[type[Robot], tuple[RobotSelectEntityDescription, ...]] = { + LitterRobot: ( + RobotSelectEntityDescription[LitterRobot, int]( # type: ignore[type-abstract] # only used for isinstance check + key="cycle_delay", + translation_key="cycle_delay", + unit_of_measurement=UnitOfTime.MINUTES, + current_fn=lambda robot: robot.clean_cycle_wait_time_minutes, + options_fn=lambda robot: robot.VALID_WAIT_TIMES, + select_fn=lambda robot, opt: robot.set_wait_time(int(opt)), + ), ), - LitterRobot4: RobotSelectEntityDescription[LitterRobot4, str]( - key="panel_brightness", - translation_key="brightness_level", - current_fn=( - lambda robot: bri.name.lower() - if (bri := robot.panel_brightness) is not None - else None + LitterRobot4: ( + RobotSelectEntityDescription[LitterRobot4, str]( + key="globe_brightness", + translation_key="globe_brightness", + current_fn=( + lambda robot: bri.name.lower() + if (bri := robot.night_light_level) is not None + else None + ), + options_fn=lambda _: [level.name.lower() for level in BrightnessLevel], + select_fn=( + lambda robot, opt: robot.set_night_light_brightness( + BrightnessLevel[opt.upper()] + ) + ), + ), + RobotSelectEntityDescription[LitterRobot4, str]( + key="globe_light", + translation_key="globe_light", + current_fn=( + lambda robot: mode.name.lower() + if (mode := robot.night_light_mode) is not None + else None + ), + options_fn=lambda _: [mode.name.lower() for mode in NightLightMode], + select_fn=( + lambda robot, opt: robot.set_night_light_mode( + NightLightMode[opt.upper()] + ) + ), ), - options_fn=lambda _: [level.name.lower() for level in BrightnessLevel], - select_fn=( - lambda robot, opt: robot.set_panel_brightness(BrightnessLevel[opt.upper()]) + RobotSelectEntityDescription[LitterRobot4, str]( + key="panel_brightness", + translation_key="brightness_level", + current_fn=( + lambda robot: bri.name.lower() + if (bri := robot.panel_brightness) is not None + else None + ), + options_fn=lambda _: [level.name.lower() for level in BrightnessLevel], + select_fn=( + lambda robot, opt: robot.set_panel_brightness( + BrightnessLevel[opt.upper()] + ) + ), ), ), - FeederRobot: RobotSelectEntityDescription[FeederRobot, float]( - key="meal_insert_size", - translation_key="meal_insert_size", - unit_of_measurement="cups", - current_fn=lambda robot: robot.meal_insert_size, - options_fn=lambda robot: robot.VALID_MEAL_INSERT_SIZES, - select_fn=lambda robot, opt: robot.set_meal_insert_size(float(opt)), + FeederRobot: ( + RobotSelectEntityDescription[FeederRobot, float]( + key="meal_insert_size", + translation_key="meal_insert_size", + unit_of_measurement="cups", + current_fn=lambda robot: robot.meal_insert_size, + options_fn=lambda robot: robot.VALID_MEAL_INSERT_SIZES, + select_fn=lambda robot, opt: robot.set_meal_insert_size(float(opt)), + ), ), } @@ -77,8 +115,9 @@ async def async_setup_entry( robot=robot, coordinator=coordinator, description=description ) for robot in coordinator.account.robots - for robot_type, description in ROBOT_SELECT_MAP.items() + for robot_type, descriptions in ROBOT_SELECT_MAP.items() if isinstance(robot, robot_type) + for description in descriptions ) diff --git a/homeassistant/components/litterrobot/strings.json b/homeassistant/components/litterrobot/strings.json index b0facf155d65d8..5bb2d7ea9c72d7 100644 --- a/homeassistant/components/litterrobot/strings.json +++ b/homeassistant/components/litterrobot/strings.json @@ -144,6 +144,22 @@ "cycle_delay": { "name": "Clean cycle wait time minutes" }, + "globe_brightness": { + "name": "Globe brightness", + "state": { + "low": "[%key:common::state::low%]", + "medium": "[%key:common::state::medium%]", + "high": "[%key:common::state::high%]" + } + }, + "globe_light": { + "name": "Globe light", + "state": { + "auto": "[%key:common::state::auto%]", + "off": "[%key:common::state::off%]", + "on": "[%key:common::state::on%]" + } + }, "meal_insert_size": { "name": "Meal insert size" }, diff --git a/tests/components/litterrobot/common.py b/tests/components/litterrobot/common.py index ad80c7cb94ab7b..a86c782a2ebb9b 100644 --- a/tests/components/litterrobot/common.py +++ b/tests/components/litterrobot/common.py @@ -39,8 +39,9 @@ "cleanCycleWaitTime": 15, "isKeypadLockout": False, "nightLightMode": "OFF", - "nightLightBrightness": 85, + "nightLightBrightness": 50, "isPanelSleepMode": False, + "panelBrightnessHigh": 50, "panelSleepTime": 0, "panelWakeTime": 0, "weekdaySleepModeEnabled": { diff --git a/tests/components/litterrobot/test_select.py b/tests/components/litterrobot/test_select.py index b4902a56e632f3..873e65b33ffda4 100644 --- a/tests/components/litterrobot/test_select.py +++ b/tests/components/litterrobot/test_select.py @@ -19,7 +19,6 @@ from .conftest import setup_integration SELECT_ENTITY_ID = "select.test_clean_cycle_wait_time_minutes" -PANEL_BRIGHTNESS_ENTITY_ID = "select.test_panel_brightness" async def test_wait_time_select( @@ -69,26 +68,38 @@ async def test_invalid_wait_time_select(hass: HomeAssistant, mock_account) -> No assert not mock_account.robots[0].set_wait_time.called -async def test_panel_brightness_select( +@pytest.mark.parametrize( + ("entity_id", "initial_value", "robot_command"), + [ + ("select.test_globe_brightness", "medium", "set_night_light_brightness"), + ("select.test_globe_light", "off", "set_night_light_mode"), + ("select.test_panel_brightness", "medium", "set_panel_brightness"), + ], +) +async def test_litterrobot_4_select( hass: HomeAssistant, mock_account_with_litterrobot_4: MagicMock, entity_registry: er.EntityRegistry, + entity_id: str, + initial_value: str, + robot_command: str, ) -> None: - """Tests the wait time select entity.""" + """Tests a Litter-Robot 4 select entity.""" await setup_integration(hass, mock_account_with_litterrobot_4, SELECT_DOMAIN) - select = hass.states.get(PANEL_BRIGHTNESS_ENTITY_ID) + select = hass.states.get(entity_id) assert select assert len(select.attributes[ATTR_OPTIONS]) == 3 + assert select.state == initial_value - entity_entry = entity_registry.async_get(PANEL_BRIGHTNESS_ENTITY_ID) + entity_entry = entity_registry.async_get(entity_id) assert entity_entry assert entity_entry.entity_category is EntityCategory.CONFIG - data = {ATTR_ENTITY_ID: PANEL_BRIGHTNESS_ENTITY_ID} + data = {ATTR_ENTITY_ID: entity_id} robot: LitterRobot4 = mock_account_with_litterrobot_4.robots[0] - robot.set_panel_brightness = AsyncMock(return_value=True) + setattr(robot, robot_command, AsyncMock(return_value=True)) for count, option in enumerate(select.attributes[ATTR_OPTIONS]): data[ATTR_OPTION] = option @@ -100,4 +111,4 @@ async def test_panel_brightness_select( blocking=True, ) - assert robot.set_panel_brightness.call_count == count + 1 + assert getattr(robot, robot_command).call_count == count + 1 From dbb29a7c7dce7766f45b38c11447d04a500606e0 Mon Sep 17 00:00:00 2001 From: Petar Petrov Date: Fri, 12 Sep 2025 22:16:06 +0300 Subject: [PATCH 26/30] Add `attributes.entity_id` to min_max sensors similar to groups (#151480) --- homeassistant/components/min_max/sensor.py | 18 ++++++++++++------ tests/components/min_max/test_sensor.py | 2 ++ 2 files changed, 14 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/min_max/sensor.py b/homeassistant/components/min_max/sensor.py index 9039c3e9e2480f..ea4491ebc79fa7 100644 --- a/homeassistant/components/min_max/sensor.py +++ b/homeassistant/components/min_max/sensor.py @@ -16,6 +16,7 @@ ) from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( + ATTR_ENTITY_ID, ATTR_UNIT_OF_MEASUREMENT, CONF_NAME, CONF_TYPE, @@ -278,13 +279,18 @@ def native_unit_of_measurement(self) -> str | None: @property def extra_state_attributes(self) -> dict[str, Any] | None: """Return the state attributes of the sensor.""" + attributes: dict[str, list[str] | str | None] = { + ATTR_ENTITY_ID: self._entity_ids + } + if self._sensor_type == "min": - return {ATTR_MIN_ENTITY_ID: self.min_entity_id} - if self._sensor_type == "max": - return {ATTR_MAX_ENTITY_ID: self.max_entity_id} - if self._sensor_type == "last": - return {ATTR_LAST_ENTITY_ID: self.last_entity_id} - return None + attributes[ATTR_MIN_ENTITY_ID] = self.min_entity_id + elif self._sensor_type == "max": + attributes[ATTR_MAX_ENTITY_ID] = self.max_entity_id + elif self._sensor_type == "last": + attributes[ATTR_LAST_ENTITY_ID] = self.last_entity_id + + return attributes @callback def _async_min_max_sensor_state_listener( diff --git a/tests/components/min_max/test_sensor.py b/tests/components/min_max/test_sensor.py index a7a70043d94eb6..c7f96e3aa2afdc 100644 --- a/tests/components/min_max/test_sensor.py +++ b/tests/components/min_max/test_sensor.py @@ -9,6 +9,7 @@ from homeassistant.components.min_max.const import DOMAIN from homeassistant.components.sensor import ATTR_STATE_CLASS, SensorStateClass from homeassistant.const import ( + ATTR_ENTITY_ID, ATTR_UNIT_OF_MEASUREMENT, PERCENTAGE, SERVICE_RELOAD, @@ -59,6 +60,7 @@ async def test_default_name_sensor(hass: HomeAssistant) -> None: assert str(float(MIN_VALUE)) == state.state assert entity_ids[2] == state.attributes.get("min_entity_id") + assert state.attributes.get(ATTR_ENTITY_ID) == entity_ids async def test_min_sensor( From a5bfdc697b12616d83e0a7e4f31ab1a2a641b60f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jozef=20La=C4=8Dn=C3=BD?= Date: Fri, 12 Sep 2025 21:16:26 +0200 Subject: [PATCH 27/30] Add MEASUREMENT state_class to temperature sensors of flexit_bacnet (#152120) --- .../components/flexit_bacnet/sensor.py | 5 ++++ .../flexit_bacnet/snapshots/test_sensor.ambr | 25 +++++++++++++++---- 2 files changed, 25 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/flexit_bacnet/sensor.py b/homeassistant/components/flexit_bacnet/sensor.py index 0506b13892b3ff..8d4ec9ce80e1b7 100644 --- a/homeassistant/components/flexit_bacnet/sensor.py +++ b/homeassistant/components/flexit_bacnet/sensor.py @@ -37,6 +37,7 @@ class FlexitSensorEntityDescription(SensorEntityDescription): FlexitSensorEntityDescription( key="outside_air_temperature", device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfTemperature.CELSIUS, translation_key="outside_air_temperature", value_fn=lambda data: data.outside_air_temperature, @@ -44,6 +45,7 @@ class FlexitSensorEntityDescription(SensorEntityDescription): FlexitSensorEntityDescription( key="supply_air_temperature", device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfTemperature.CELSIUS, translation_key="supply_air_temperature", value_fn=lambda data: data.supply_air_temperature, @@ -51,6 +53,7 @@ class FlexitSensorEntityDescription(SensorEntityDescription): FlexitSensorEntityDescription( key="exhaust_air_temperature", device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfTemperature.CELSIUS, translation_key="exhaust_air_temperature", value_fn=lambda data: data.exhaust_air_temperature, @@ -58,6 +61,7 @@ class FlexitSensorEntityDescription(SensorEntityDescription): FlexitSensorEntityDescription( key="extract_air_temperature", device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfTemperature.CELSIUS, translation_key="extract_air_temperature", value_fn=lambda data: data.extract_air_temperature, @@ -65,6 +69,7 @@ class FlexitSensorEntityDescription(SensorEntityDescription): FlexitSensorEntityDescription( key="room_temperature", device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfTemperature.CELSIUS, translation_key="room_temperature", value_fn=lambda data: data.room_temperature, diff --git a/tests/components/flexit_bacnet/snapshots/test_sensor.ambr b/tests/components/flexit_bacnet/snapshots/test_sensor.ambr index c3c3b8f185d0f6..8236540654d113 100644 --- a/tests/components/flexit_bacnet/snapshots/test_sensor.ambr +++ b/tests/components/flexit_bacnet/snapshots/test_sensor.ambr @@ -216,7 +216,9 @@ 'aliases': set({ }), 'area_id': None, - 'capabilities': None, + 'capabilities': dict({ + 'state_class': , + }), 'config_entry_id': , 'config_subentry_id': , 'device_class': None, @@ -254,6 +256,7 @@ 'attributes': ReadOnlyDict({ 'device_class': 'temperature', 'friendly_name': 'Device Name Exhaust air temperature', + 'state_class': , 'unit_of_measurement': , }), 'context': , @@ -269,7 +272,9 @@ 'aliases': set({ }), 'area_id': None, - 'capabilities': None, + 'capabilities': dict({ + 'state_class': , + }), 'config_entry_id': , 'config_subentry_id': , 'device_class': None, @@ -307,6 +312,7 @@ 'attributes': ReadOnlyDict({ 'device_class': 'temperature', 'friendly_name': 'Device Name Extract air temperature', + 'state_class': , 'unit_of_measurement': , }), 'context': , @@ -482,7 +488,9 @@ 'aliases': set({ }), 'area_id': None, - 'capabilities': None, + 'capabilities': dict({ + 'state_class': , + }), 'config_entry_id': , 'config_subentry_id': , 'device_class': None, @@ -520,6 +528,7 @@ 'attributes': ReadOnlyDict({ 'device_class': 'temperature', 'friendly_name': 'Device Name Outside air temperature', + 'state_class': , 'unit_of_measurement': , }), 'context': , @@ -591,7 +600,9 @@ 'aliases': set({ }), 'area_id': None, - 'capabilities': None, + 'capabilities': dict({ + 'state_class': , + }), 'config_entry_id': , 'config_subentry_id': , 'device_class': None, @@ -629,6 +640,7 @@ 'attributes': ReadOnlyDict({ 'device_class': 'temperature', 'friendly_name': 'Device Name Room temperature', + 'state_class': , 'unit_of_measurement': , }), 'context': , @@ -748,7 +760,9 @@ 'aliases': set({ }), 'area_id': None, - 'capabilities': None, + 'capabilities': dict({ + 'state_class': , + }), 'config_entry_id': , 'config_subentry_id': , 'device_class': None, @@ -786,6 +800,7 @@ 'attributes': ReadOnlyDict({ 'device_class': 'temperature', 'friendly_name': 'Device Name Supply air temperature', + 'state_class': , 'unit_of_measurement': , }), 'context': , From dcd09523a68f223f16d7bfd2fc8fbbd578db7a94 Mon Sep 17 00:00:00 2001 From: RoboMagus <68224306+RoboMagus@users.noreply.github.com> Date: Fri, 12 Sep 2025 21:48:52 +0200 Subject: [PATCH 28/30] Webhook trigger: Enable templated webhook_id (#151193) --- homeassistant/components/webhook/trigger.py | 13 ++++-- tests/components/webhook/test_trigger.py | 44 +++++++++++++++++++++ 2 files changed, 54 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/webhook/trigger.py b/homeassistant/components/webhook/trigger.py index 907123561f79ff..3cc27a6f7e1380 100644 --- a/homeassistant/components/webhook/trigger.py +++ b/homeassistant/components/webhook/trigger.py @@ -12,8 +12,9 @@ from homeassistant.const import CONF_PLATFORM, CONF_WEBHOOK_ID from homeassistant.core import CALLBACK_TYPE, HassJob, HomeAssistant, callback from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.template import Template from homeassistant.helpers.trigger import TriggerActionType, TriggerInfo -from homeassistant.helpers.typing import ConfigType +from homeassistant.helpers.typing import ConfigType, TemplateVarsType from . import ( DEFAULT_METHODS, @@ -33,7 +34,7 @@ TRIGGER_SCHEMA = cv.TRIGGER_BASE_SCHEMA.extend( { vol.Required(CONF_PLATFORM): "webhook", - vol.Required(CONF_WEBHOOK_ID): cv.string, + vol.Required(CONF_WEBHOOK_ID): cv.template, vol.Optional(CONF_ALLOWED_METHODS): vol.All( cv.ensure_list, [vol.All(vol.Upper, vol.In(SUPPORTED_METHODS))], @@ -83,7 +84,13 @@ async def async_attach_trigger( trigger_info: TriggerInfo, ) -> CALLBACK_TYPE: """Trigger based on incoming webhooks.""" - webhook_id: str = config[CONF_WEBHOOK_ID] + variables: TemplateVarsType | None = None + if trigger_info: + variables = trigger_info.get("variables") + webhook_id_template: Template = config[CONF_WEBHOOK_ID] + webhook_id: str = webhook_id_template.async_render( + variables, limited=True, parse_result=False + ) local_only = config.get(CONF_LOCAL_ONLY, True) allowed_methods = config.get(CONF_ALLOWED_METHODS, DEFAULT_METHODS) job = HassJob(action) diff --git a/tests/components/webhook/test_trigger.py b/tests/components/webhook/test_trigger.py index 2963db70ad4eb8..74a2d15b9ba948 100644 --- a/tests/components/webhook/test_trigger.py +++ b/tests/components/webhook/test_trigger.py @@ -333,3 +333,47 @@ def store_event(event): assert len(events) == 2 assert events[1].data["hello"] == "yo2 world" + + +async def test_webhook_template( + hass: HomeAssistant, hass_client_no_auth: ClientSessionGenerator +) -> None: + """Test triggering with a template webhook.""" + # Set up fake cloud + hass.config.components.add("cloud") + + events = [] + + @callback + def store_event(event): + """Help store events.""" + events.append(event) + + hass.bus.async_listen("test_success", store_event) + + assert await async_setup_component( + hass, + "automation", + { + "automation": { + "trigger": { + "platform": "webhook", + "webhook_id": "webhook-{{ sqrt(9)|round }}", + "local_only": True, + }, + "action": { + "event": "test_success", + "event_data_template": {"hello": "yo {{ trigger.data.hello }}"}, + }, + } + }, + ) + await hass.async_block_till_done() + + client = await hass_client_no_auth() + + await client.post("/api/webhook/webhook-3", data={"hello": "world"}) + await hass.async_block_till_done() + + assert len(events) == 1 + assert events[0].data["hello"] == "yo world" From 3472020812f80906beb1bd0cae8da98e42af207e Mon Sep 17 00:00:00 2001 From: Shay Levy Date: Fri, 12 Sep 2025 23:09:01 +0300 Subject: [PATCH 29/30] Add icons for volume flow rate (#152196) --- homeassistant/components/number/icons.json | 3 +++ homeassistant/components/sensor/icons.json | 3 +++ 2 files changed, 6 insertions(+) diff --git a/homeassistant/components/number/icons.json b/homeassistant/components/number/icons.json index 482b4bc679321b..9d75e09a72ddb1 100644 --- a/homeassistant/components/number/icons.json +++ b/homeassistant/components/number/icons.json @@ -147,6 +147,9 @@ "volume": { "default": "mdi:car-coolant-level" }, + "volume_flow_rate": { + "default": "mdi:pipe-valve" + }, "volume_storage": { "default": "mdi:storage-tank" }, diff --git a/homeassistant/components/sensor/icons.json b/homeassistant/components/sensor/icons.json index cea955e061c458..740b2df7e5b37f 100644 --- a/homeassistant/components/sensor/icons.json +++ b/homeassistant/components/sensor/icons.json @@ -169,6 +169,9 @@ "volume": { "default": "mdi:car-coolant-level" }, + "volume_flow_rate": { + "default": "mdi:pipe-valve" + }, "volume_storage": { "default": "mdi:storage-tank" }, From 0ac7cb311d40a96981c76d7f5fe4b0e5015478f0 Mon Sep 17 00:00:00 2001 From: hbludworth <63749412+hbludworth@users.noreply.github.com> Date: Fri, 12 Sep 2025 14:16:37 -0600 Subject: [PATCH 30/30] Fix Aladdin Connect state not updating (#151652) Co-authored-by: Joostlek --- .../components/aladdin_connect/__init__.py | 18 +----------------- .../components/aladdin_connect/coordinator.py | 6 ++++++ .../components/aladdin_connect/cover.py | 4 +++- .../components/aladdin_connect/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/aladdin_connect/test_init.py | 2 ++ 7 files changed, 15 insertions(+), 21 deletions(-) diff --git a/homeassistant/components/aladdin_connect/__init__.py b/homeassistant/components/aladdin_connect/__init__.py index adcc53bfc75c8e..48bedafdd1ab8f 100644 --- a/homeassistant/components/aladdin_connect/__init__.py +++ b/homeassistant/components/aladdin_connect/__init__.py @@ -3,7 +3,6 @@ from __future__ import annotations from genie_partner_sdk.client import AladdinConnectClient -from genie_partner_sdk.model import GarageDoor from homeassistant.const import Platform from homeassistant.core import HomeAssistant @@ -36,22 +35,7 @@ async def async_setup_entry( api.AsyncConfigEntryAuth(aiohttp_client.async_get_clientsession(hass), session) ) - sdk_doors = await client.get_doors() - - # Convert SDK GarageDoor objects to integration GarageDoor objects - doors = [ - GarageDoor( - { - "device_id": door.device_id, - "door_number": door.door_number, - "name": door.name, - "status": door.status, - "link_status": door.link_status, - "battery_level": door.battery_level, - } - ) - for door in sdk_doors - ] + doors = await client.get_doors() entry.runtime_data = { door.unique_id: AladdinConnectCoordinator(hass, entry, client, door) diff --git a/homeassistant/components/aladdin_connect/coordinator.py b/homeassistant/components/aladdin_connect/coordinator.py index 74afbe8fca9727..718aed8e44572c 100644 --- a/homeassistant/components/aladdin_connect/coordinator.py +++ b/homeassistant/components/aladdin_connect/coordinator.py @@ -41,4 +41,10 @@ def __init__( async def _async_update_data(self) -> GarageDoor: """Fetch data from the Aladdin Connect API.""" await self.client.update_door(self.data.device_id, self.data.door_number) + self.data.status = self.client.get_door_status( + self.data.device_id, self.data.door_number + ) + self.data.battery_level = self.client.get_battery_status( + self.data.device_id, self.data.door_number + ) return self.data diff --git a/homeassistant/components/aladdin_connect/cover.py b/homeassistant/components/aladdin_connect/cover.py index 7af0e4eb2cee2b..4bc787539fd9d2 100644 --- a/homeassistant/components/aladdin_connect/cover.py +++ b/homeassistant/components/aladdin_connect/cover.py @@ -49,7 +49,9 @@ async def async_close_cover(self, **kwargs: Any) -> None: @property def is_closed(self) -> bool | None: """Update is closed attribute.""" - return self.coordinator.data.status == "closed" + if (status := self.coordinator.data.status) is None: + return None + return status == "closed" @property def is_closing(self) -> bool | None: diff --git a/homeassistant/components/aladdin_connect/manifest.json b/homeassistant/components/aladdin_connect/manifest.json index 8165ebd4ac9913..e19d5c61d049fd 100644 --- a/homeassistant/components/aladdin_connect/manifest.json +++ b/homeassistant/components/aladdin_connect/manifest.json @@ -12,5 +12,5 @@ "documentation": "https://www.home-assistant.io/integrations/aladdin_connect", "integration_type": "hub", "iot_class": "cloud_polling", - "requirements": ["genie-partner-sdk==1.0.10"] + "requirements": ["genie-partner-sdk==1.0.11"] } diff --git a/requirements_all.txt b/requirements_all.txt index 862802fc03d5c3..a192b85e0c1d5b 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1005,7 +1005,7 @@ gassist-text==0.0.14 gcal-sync==8.0.0 # homeassistant.components.aladdin_connect -genie-partner-sdk==1.0.10 +genie-partner-sdk==1.0.11 # homeassistant.components.geniushub geniushub-client==0.7.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 81c786b2265e37..b026a547cfc9b9 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -875,7 +875,7 @@ gassist-text==0.0.14 gcal-sync==8.0.0 # homeassistant.components.aladdin_connect -genie-partner-sdk==1.0.10 +genie-partner-sdk==1.0.11 # homeassistant.components.geniushub geniushub-client==0.7.1 diff --git a/tests/components/aladdin_connect/test_init.py b/tests/components/aladdin_connect/test_init.py index e26e5234f1c272..bc147839c2fed4 100644 --- a/tests/components/aladdin_connect/test_init.py +++ b/tests/components/aladdin_connect/test_init.py @@ -30,6 +30,7 @@ async def test_setup_entry(hass: HomeAssistant) -> None: mock_door.status = "closed" mock_door.link_status = "connected" mock_door.battery_level = 100 + mock_door.unique_id = f"{mock_door.device_id}-{mock_door.door_number}" mock_client = AsyncMock() mock_client.get_doors.return_value = [mock_door] @@ -80,6 +81,7 @@ async def test_unload_entry(hass: HomeAssistant) -> None: mock_door.status = "closed" mock_door.link_status = "connected" mock_door.battery_level = 100 + mock_door.unique_id = f"{mock_door.device_id}-{mock_door.door_number}" # Mock client mock_client = AsyncMock()