From 35eead1b86fcf60c1bd7917c1a6dc372b472c6f3 Mon Sep 17 00:00:00 2001 From: Manu Date: Thu, 17 Sep 2026 07:29:00 +0200 Subject: [PATCH 1/7] Fix typo in codeowner handle (#182445) --- CODEOWNERS | 4 ++-- homeassistant/components/picotts/manifest.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/CODEOWNERS b/CODEOWNERS index 955895265893bd..e2bc0385e51818 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1437,8 +1437,8 @@ CLAUDE.md @home-assistant/core /tests/components/pi_hole/ @shenxn /homeassistant/components/picnic/ @corneyl @codesalatdev /tests/components/picnic/ @corneyl @codesalatdev -/homeassistant/components/picotts/ @rooggiieerr -/tests/components/picotts/ @rooggiieerr +/homeassistant/components/picotts/ @rrooggiieerr +/tests/components/picotts/ @rrooggiieerr /homeassistant/components/ping/ @jpbede /tests/components/ping/ @jpbede /homeassistant/components/plaato/ @JohNan diff --git a/homeassistant/components/picotts/manifest.json b/homeassistant/components/picotts/manifest.json index 20ca5487e9457f..85078ad5841ada 100644 --- a/homeassistant/components/picotts/manifest.json +++ b/homeassistant/components/picotts/manifest.json @@ -1,7 +1,7 @@ { "domain": "picotts", "name": "Pico TTS", - "codeowners": ["@rooggiieerr"], + "codeowners": ["@rrooggiieerr"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/picotts", "integration_type": "service", From c1bc07270ff934d050106fa718f2e2b646379f01 Mon Sep 17 00:00:00 2001 From: Simone Chemelli Date: Thu, 17 Sep 2026 07:34:59 +0200 Subject: [PATCH 2/7] Log exception when api fails in WAQI (#182419) --- homeassistant/components/waqi/coordinator.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/waqi/coordinator.py b/homeassistant/components/waqi/coordinator.py index 46b08e2e8a3e35..53624e198d8894 100644 --- a/homeassistant/components/waqi/coordinator.py +++ b/homeassistant/components/waqi/coordinator.py @@ -44,4 +44,4 @@ async def _async_update_data(self) -> WAQIAirQuality: self.subentry.data[CONF_STATION_NUMBER] ) except WAQIError as exc: - raise UpdateFailed from exc + raise UpdateFailed(str(exc)) from exc From 2450d1f8cedd403193d77ae59a9993f2f28a2d8e Mon Sep 17 00:00:00 2001 From: karwosts <32912880+karwosts@users.noreply.github.com> Date: Thu, 17 Sep 2026 13:41:56 +0800 Subject: [PATCH 3/7] Exempt devices scale item for collection_image (#182444) --- homeassistant/components/collection_image/quality_scale.yaml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/collection_image/quality_scale.yaml b/homeassistant/components/collection_image/quality_scale.yaml index e4963d9a45a422..34c064ff239f6f 100644 --- a/homeassistant/components/collection_image/quality_scale.yaml +++ b/homeassistant/components/collection_image/quality_scale.yaml @@ -60,7 +60,9 @@ rules: test-coverage: done # Gold - devices: todo + devices: + status: exempt + comment: Adding a device adds no benefit for a single entity config entry. diagnostics: todo discovery-update-info: status: exempt From 81230a3d48af62274694365f9e28a68cff5b7a68 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Thu, 17 Sep 2026 07:48:45 +0200 Subject: [PATCH 4/7] Update uv to 0.12.12 (#182447) --- homeassistant/package_constraints.txt | 2 +- pyproject.toml | 2 +- requirements.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 3eb7b74ba0d3f3..fba2da70527978 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -72,7 +72,7 @@ standard-telnetlib==3.13.0 typing-extensions>=4.16.0,<5.0 ulid-transform==2.2.9 urllib3>=2.0 -uv==0.12.11 +uv==0.12.12 webrtc-models==0.3.0 yarl==1.25.1 zeroconf==0.151.3 diff --git a/pyproject.toml b/pyproject.toml index fd1ebb770134b3..83cc5aa78381d2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -77,7 +77,7 @@ dependencies = [ "typing-extensions>=4.16.0,<5.0", "ulid-transform==2.2.9", "urllib3>=2.0", - "uv==0.12.11", + "uv==0.12.12", "probatio==0.12.1", "yarl==1.25.1", "webrtc-models==0.3.0", diff --git a/requirements.txt b/requirements.txt index 2f227208c585b5..737f3bb4c5384d 100644 --- a/requirements.txt +++ b/requirements.txt @@ -57,7 +57,7 @@ standard-telnetlib==3.13.0 typing-extensions>=4.16.0,<5.0 ulid-transform==2.2.9 urllib3>=2.0 -uv==0.12.11 +uv==0.12.12 webrtc-models==0.3.0 yarl==1.25.1 zeroconf==0.151.3 From 76665ef504a5004d7a651699869fca0d2d7d89b0 Mon Sep 17 00:00:00 2001 From: Brett Adams Date: Thu, 17 Sep 2026 16:09:15 +1000 Subject: [PATCH 5/7] Honor rate limit retry hint in Tesla Fleet vehicle coordinator (#181661) --- .../components/tesla_fleet/coordinator.py | 37 ++++---- tests/components/tesla_fleet/test_init.py | 93 +++++++++++++++++-- 2 files changed, 105 insertions(+), 25 deletions(-) diff --git a/homeassistant/components/tesla_fleet/coordinator.py b/homeassistant/components/tesla_fleet/coordinator.py index 8fb8a3f3cf6d76..0fa6d7e05e9a4e 100644 --- a/homeassistant/components/tesla_fleet/coordinator.py +++ b/homeassistant/components/tesla_fleet/coordinator.py @@ -152,6 +152,8 @@ def __init__( async def _async_update_data(self) -> dict[str, Any]: """Update vehicle data using TeslaFleet API.""" + self.update_interval = VEHICLE_INTERVAL + try: # Check if the vehicle is awake using a free API call response = await self.api.vehicle() @@ -166,11 +168,16 @@ async def _async_update_data(self) -> dict[str, Any]: except VehicleOffline: self.data["state"] = TeslaFleetState.ASLEEP return self.data - except RateLimited: - LOGGER.warning( - "%s rate limited, will skip refresh", - self.name, - ) + except RateLimited as e: + if isinstance(e.data, dict) and (after := e.data.get("after")): + LOGGER.warning( + "%s rate limited, will retry in %s seconds", + self.name, + after, + ) + self.update_interval = timedelta(seconds=int(after)) + else: + LOGGER.warning("%s rate limited, will skip refresh", self.name) return self.data except (InvalidToken, OAuthExpired) as e: _invalidate_access_token(self.hass, self.config_entry) @@ -180,8 +187,6 @@ async def _async_update_data(self) -> dict[str, Any]: except TeslaFleetError as e: raise UpdateFailed(e.message) from e - self.update_interval = VEHICLE_INTERVAL - self.updated_once = True if self.api.pre2021 and data["state"] == TeslaFleetState.ONLINE: @@ -238,13 +243,13 @@ async def _async_update_data(self) -> dict[str, Any]: try: data = (await self.api.live_status())["response"] except RateLimited as e: - if isinstance(e.data, dict) and "after" in e.data: + if isinstance(e.data, dict) and (after := e.data.get("after")): LOGGER.warning( "%s rate limited, will retry in %s seconds", self.name, - e.data["after"], + after, ) - self.update_interval = timedelta(seconds=int(e.data["after"])) + self.update_interval = timedelta(seconds=int(after)) else: LOGGER.warning("%s rate limited, will skip refresh", self.name) return self.data @@ -320,13 +325,13 @@ async def _async_update_data(self) -> dict[str, Any]: try: data = (await self.api.energy_history(TeslaEnergyPeriod.DAY))["response"] except RateLimited as e: - if isinstance(e.data, dict) and "after" in e.data: + if isinstance(e.data, dict) and (after := e.data.get("after")): LOGGER.warning( "%s rate limited, will retry in %s seconds", self.name, - e.data["after"], + after, ) - self.update_interval = timedelta(seconds=int(e.data["after"])) + self.update_interval = timedelta(seconds=int(after)) else: LOGGER.warning("%s rate limited, will skip refresh", self.name) return self.data @@ -401,13 +406,13 @@ async def _async_update_data(self) -> dict[str, Any]: try: data = (await self.api.site_info())["response"] except RateLimited as e: - if isinstance(e.data, dict) and "after" in e.data: + if isinstance(e.data, dict) and (after := e.data.get("after")): LOGGER.warning( "%s rate limited, will retry in %s seconds", self.name, - e.data["after"], + after, ) - self.update_interval = timedelta(seconds=int(e.data["after"])) + self.update_interval = timedelta(seconds=int(after)) else: LOGGER.warning("%s rate limited, will skip refresh", self.name) return self.data diff --git a/tests/components/tesla_fleet/test_init.py b/tests/components/tesla_fleet/test_init.py index 4aa8b8b44c44e8..e660cb767de180 100644 --- a/tests/components/tesla_fleet/test_init.py +++ b/tests/components/tesla_fleet/test_init.py @@ -377,31 +377,102 @@ async def test_vehicle_refresh_ratelimited( mock_vehicle_data: AsyncMock, freezer: FrozenDateTimeFactory, ) -> None: - """Test coordinator refresh handles 429.""" + """Test coordinator refresh handles 429 and backs off using the after hint.""" + + await setup_platform(hass, normal_config_entry) + + after_seconds = VEHICLE_INTERVAL_SECONDS + 10 + mock_vehicle_data.side_effect = RateLimited({"after": str(after_seconds)}) + freezer.tick(VEHICLE_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert mock_vehicle_data.call_count == 2 + assert (state := hass.states.get("sensor.test_battery_level")) + assert state.state == "77" + + freezer.tick(VEHICLE_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + # Not yet past the after hint, must not call + assert mock_vehicle_data.call_count == 2 + + freezer.tick(timedelta(seconds=after_seconds - VEHICLE_INTERVAL_SECONDS)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + # Exactly past the after hint, must call + assert mock_vehicle_data.call_count == 3 + + +async def test_vehicle_refresh_ratelimited_on_first_refresh( + hass: HomeAssistant, + normal_config_entry: MockConfigEntry, + mock_vehicle_data: AsyncMock, +) -> None: + """Test coordinator handles 429 on the first refresh, before any data exists.""" mock_vehicle_data.side_effect = RateLimited( - {"after": VEHICLE_INTERVAL_SECONDS + 10} + {"after": str(VEHICLE_INTERVAL_SECONDS + 10)} ) await setup_platform(hass, normal_config_entry) assert (state := hass.states.get("sensor.test_battery_level")) assert state.state == "unknown" - mock_vehicle_data.reset_mock() +async def test_vehicle_refresh_ratelimited_backoff_not_sticky( + hass: HomeAssistant, + normal_config_entry: MockConfigEntry, + mock_vehicle_state: AsyncMock, + mock_vehicle_data: AsyncMock, + freezer: FrozenDateTimeFactory, +) -> None: + """Test the after backoff does not persist through an asleep refresh.""" + + await setup_platform(hass, normal_config_entry) + + after_seconds = VEHICLE_INTERVAL_SECONDS * 3 + mock_vehicle_data.side_effect = RateLimited({"after": str(after_seconds)}) freezer.tick(VEHICLE_INTERVAL) async_fire_time_changed(hass) await hass.async_block_till_done() - assert (state := hass.states.get("sensor.test_battery_level")) - assert state.state == "unknown" + assert mock_vehicle_state.call_count == 2 + assert mock_vehicle_data.call_count == 2 + + # The vehicle falls asleep, so the next refresh returns before vehicle_data + mock_vehicle_state.return_value = VEHICLE_ASLEEP + freezer.tick(timedelta(seconds=after_seconds)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert mock_vehicle_state.call_count == 3 + assert mock_vehicle_data.call_count == 2 + + freezer.tick(VEHICLE_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + # Back on the normal interval rather than the after backoff + assert mock_vehicle_state.call_count == 4 + +@pytest.mark.parametrize( + "data", + [ + pytest.param({}, id="missing"), + # The library sets after to None when the Retry-After header is absent + pytest.param({"reset": None, "after": None}, id="none"), + ], +) async def test_vehicle_refresh_ratelimited_no_after( hass: HomeAssistant, normal_config_entry: MockConfigEntry, mock_vehicle_data: AsyncMock, freezer: FrozenDateTimeFactory, + data: dict[str, str | None], ) -> None: """Test coordinator refresh handles 429 without after.""" @@ -409,13 +480,15 @@ async def test_vehicle_refresh_ratelimited_no_after( # mock_vehicle_data called once during setup assert mock_vehicle_data.call_count == 1 - mock_vehicle_data.side_effect = RateLimited({}) + mock_vehicle_data.side_effect = RateLimited(data) freezer.tick(VEHICLE_INTERVAL) async_fire_time_changed(hass) await hass.async_block_till_done() # Called again during refresh, failed with RateLimited assert mock_vehicle_data.call_count == 2 + assert (state := hass.states.get("sensor.test_battery_level")) + assert state.state == "77" freezer.tick(VEHICLE_INTERVAL) async_fire_time_changed(hass) @@ -773,7 +846,9 @@ async def test_energy_live_refresh_ratelimited( await setup_platform(hass, normal_config_entry) - mock_live_status.side_effect = RateLimited({"after": ENERGY_INTERVAL_SECONDS + 10}) + mock_live_status.side_effect = RateLimited( + {"after": str(ENERGY_INTERVAL_SECONDS + 10)} + ) freezer.tick(ENERGY_INTERVAL) async_fire_time_changed(hass) await hass.async_block_till_done() @@ -797,7 +872,7 @@ async def test_energy_live_refresh_ratelimited( @pytest.mark.parametrize( ("side_effect", "second_refresh_call_count", "third_refresh_call_count"), [ - (RateLimited({"after": ENERGY_INTERVAL_SECONDS + 10}), 2, 3), + (RateLimited({"after": str(ENERGY_INTERVAL_SECONDS + 10)}), 2, 3), (RateLimited({}), 3, 4), ], ) @@ -847,7 +922,7 @@ async def test_energy_history_refresh_ratelimited( await setup_platform(hass, normal_config_entry) mock_energy_history.side_effect = RateLimited( - {"after": int(ENERGY_HISTORY_INTERVAL.total_seconds() + 10)} + {"after": str(int(ENERGY_HISTORY_INTERVAL.total_seconds() + 10))} ) freezer.tick(ENERGY_HISTORY_INTERVAL) async_fire_time_changed(hass) From f79fc69b3fc2f410c13b3315f3a5eab4cb0f1e70 Mon Sep 17 00:00:00 2001 From: darkrain-nl Date: Thu, 17 Sep 2026 10:29:33 +0200 Subject: [PATCH 6/7] Mark the Sofar integration as platinum quality scale (#182291) --- homeassistant/components/sofar/manifest.json | 2 +- homeassistant/components/sofar/quality_scale.yaml | 12 ++++++++---- 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/sofar/manifest.json b/homeassistant/components/sofar/manifest.json index 9b28e1deff3924..d694502b69217a 100644 --- a/homeassistant/components/sofar/manifest.json +++ b/homeassistant/components/sofar/manifest.json @@ -7,6 +7,6 @@ "documentation": "https://www.home-assistant.io/integrations/sofar", "integration_type": "device", "iot_class": "local_polling", - "quality_scale": "silver", + "quality_scale": "platinum", "requirements": ["sofar-modbus==0.14.0"] } diff --git a/homeassistant/components/sofar/quality_scale.yaml b/homeassistant/components/sofar/quality_scale.yaml index b394b2af15426b..05ebfeba13d635 100644 --- a/homeassistant/components/sofar/quality_scale.yaml +++ b/homeassistant/components/sofar/quality_scale.yaml @@ -30,7 +30,9 @@ rules: # Silver action-exceptions: done config-entry-unloading: done - docs-configuration-parameters: done + docs-configuration-parameters: + status: exempt + comment: This integration does not provide an options flow. docs-installation-parameters: done entity-unavailable: done integration-owner: done @@ -51,12 +53,12 @@ rules: status: exempt comment: Modbus TCP gateways are not announced over any discoverable protocol. docs-data-update: done - docs-examples: todo + docs-examples: done docs-known-limitations: done docs-supported-devices: done docs-supported-functions: done docs-troubleshooting: done - docs-use-cases: todo + docs-use-cases: done dynamic-devices: done entity-category: done entity-device-class: done @@ -65,7 +67,9 @@ rules: exception-translations: done icon-translations: done reconfiguration-flow: done - repair-issues: todo + repair-issues: + status: exempt + comment: This integration has no user-actionable issues to repair. stale-devices: done # Platinum From c8ed5b7a6e0d35116b31b6a19526c538466f0bf5 Mon Sep 17 00:00:00 2001 From: Simone Chemelli Date: Thu, 17 Sep 2026 11:21:20 +0200 Subject: [PATCH 7/7] Update IQS rules status for Midea (#181773) --- .../components/midea/quality_scale.yaml | 51 +++++++++++++------ 1 file changed, 35 insertions(+), 16 deletions(-) diff --git a/homeassistant/components/midea/quality_scale.yaml b/homeassistant/components/midea/quality_scale.yaml index 0d6be4efe5daa3..31475a44aa7a9d 100644 --- a/homeassistant/components/midea/quality_scale.yaml +++ b/homeassistant/components/midea/quality_scale.yaml @@ -34,18 +34,31 @@ rules: config-entry-unloading: done docs-configuration-parameters: done docs-installation-parameters: done - entity-unavailable: todo + entity-unavailable: done integration-owner: done log-when-unavailable: done parallel-updates: done - reauthentication-flow: todo + reauthentication-flow: + status: exempt + comment: > + Cloud login is only used transiently during the config flow to mint a + local device token/key, which is then stored and used for local polling + only; the cloud is never contacted again. midealocal.device.connect() + also swallows AuthException into a plain bool, so a rejected token/key + is indistinguishable from the device being offline and there is no + signal that could trigger a reauth flow. test-coverage: done - # Gold - devices: todo - diagnostics: todo - discovery-update-info: todo - discovery: todo + devices: done + diagnostics: done + discovery-update-info: done + discovery: + status: exempt + comment: > + DHCP is used to synchronize the IP address of already configured devices + when their DHCP lease changes. It is not used for device discovery because + DHCP information alone is insufficient to identify and configure most supported + Midea appliances. docs-data-update: done docs-examples: done docs-known-limitations: done @@ -53,18 +66,24 @@ rules: docs-supported-functions: done docs-troubleshooting: done docs-use-cases: done - dynamic-devices: todo + dynamic-devices: + status: exempt + comment: One config entry per physical device, no hub. entity-category: todo - entity-device-class: todo - entity-disabled-by-default: todo - entity-translations: todo - exception-translations: todo - icon-translations: todo + entity-device-class: done + entity-disabled-by-default: done + entity-translations: done + exception-translations: done + icon-translations: done reconfiguration-flow: done - repair-issues: todo - stale-devices: todo + repair-issues: + status: exempt + comment: No known use cases for repair issues, yet. + stale-devices: + status: exempt + comment: One config entry per physical device, no hub. # Platinum async-dependency: todo - inject-websession: todo + inject-websession: done strict-typing: done