From ee1976dc07cd92e3f7e5e7f9ea2fc22849d8f634 Mon Sep 17 00:00:00 2001 From: Frank Kopp Date: Mon, 7 Sep 2026 07:48:15 +0200 Subject: [PATCH 01/20] Add l/min unit mapping to nibe_heatpump sensors (#181433) --- homeassistant/components/nibe_heatpump/sensor.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/homeassistant/components/nibe_heatpump/sensor.py b/homeassistant/components/nibe_heatpump/sensor.py index 3982c64c12432a..1e5d12499a9d8a 100644 --- a/homeassistant/components/nibe_heatpump/sensor.py +++ b/homeassistant/components/nibe_heatpump/sensor.py @@ -164,6 +164,13 @@ state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfVolumeFlowRate.LITERS_PER_MINUTE, ), + "l/min": SensorEntityDescription( + key="l/min", + entity_category=EntityCategory.DIAGNOSTIC, + device_class=SensorDeviceClass.VOLUME_FLOW_RATE, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfVolumeFlowRate.LITERS_PER_MINUTE, + ), "m³/h": SensorEntityDescription( key="m³/h", entity_category=EntityCategory.DIAGNOSTIC, From ce022ea2d56b8921cd01e661f95383a6c4463012 Mon Sep 17 00:00:00 2001 From: Jason Dillingham <31942663+jasondillingham@users.noreply.github.com> Date: Mon, 7 Sep 2026 00:58:15 -0500 Subject: [PATCH 02/20] Don't mutate the module level Roomba SENSORS list (#181107) --- homeassistant/components/roomba/sensor.py | 2 +- tests/components/roomba/test_sensor.py | 78 ++++++++++++++++++++++- 2 files changed, 78 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/roomba/sensor.py b/homeassistant/components/roomba/sensor.py index 10c27fbd2d14f4..159c883b5c8d9c 100644 --- a/homeassistant/components/roomba/sensor.py +++ b/homeassistant/components/roomba/sensor.py @@ -149,7 +149,7 @@ async def async_setup_entry( roomba = domain_data.roomba blid = domain_data.blid - sensor_list: list[RoombaSensorEntityDescription] = SENSORS + sensor_list: list[RoombaSensorEntityDescription] = list(SENSORS) has_dock: bool = len(roomba_reported_state(roomba).get("dock", {})) > 0 diff --git a/tests/components/roomba/test_sensor.py b/tests/components/roomba/test_sensor.py index fd56a6e9b3ff7d..2b54e468175bff 100644 --- a/tests/components/roomba/test_sensor.py +++ b/tests/components/roomba/test_sensor.py @@ -1,17 +1,43 @@ """Tests for IRobotEntity usage in Roomba sensor platform.""" +import copy from unittest.mock import AsyncMock, patch import pytest from syrupy.assertion import SnapshotAssertion -from homeassistant.const import Platform +from homeassistant.components.roomba.const import CONF_BLID, CONF_CONTINUOUS, DOMAIN +from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN +from homeassistant.const import CONF_DELAY, CONF_HOST, CONF_PASSWORD, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er from tests.common import MockConfigEntry, snapshot_platform +def _config_entry(blid: str) -> MockConfigEntry: + """Return a config entry for an additional robot.""" + return MockConfigEntry( + domain=DOMAIN, + data={ + CONF_HOST: "192.168.0.30", + CONF_BLID: blid, + CONF_PASSWORD: "pass123", + }, + options={CONF_CONTINUOUS: True, CONF_DELAY: 10}, + unique_id=blid, + ) + + +def _dock_tank_level_entities(hass: HomeAssistant) -> list[str]: + """Return every dock tank level entity currently set up.""" + return sorted( + entity_id + for entity_id in hass.states.async_entity_ids(SENSOR_DOMAIN) + if "dock_tank_level" in entity_id + ) + + @pytest.mark.usefixtures("entity_registry_enabled_by_default") async def test_entities( hass: HomeAssistant, @@ -27,3 +53,53 @@ async def test_entities( await hass.async_block_till_done() await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +async def test_two_docked_robots_do_not_collide( + hass: HomeAssistant, + mock_roomba: AsyncMock, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test a second docked robot does not produce a duplicate dock sensor. + + The dock sensors used to be appended to the module level SENSORS list, so + setting up a second docked robot appended them twice. The duplicate is + dropped by the entity platform, so the surviving entity count still looks + correct and only the logged error reveals the problem. + """ + with patch("homeassistant.components.roomba.PLATFORMS", [Platform.SENSOR]): + for blid in ("blid_first", "blid_second"): + config_entry = _config_entry(blid) + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert "does not generate unique IDs" not in caplog.text + assert len(_dock_tank_level_entities(hass)) == 2 + + +async def test_robot_without_dock_has_no_dock_sensor( + hass: HomeAssistant, + mock_roomba: AsyncMock, +) -> None: + """Test a robot without a dock does not get a dock sensor. + + A robot reporting no dock used to inherit one when it was set up after a + robot that did have one. + """ + docked_state = copy.deepcopy(mock_roomba.master_state) + dockless_state = copy.deepcopy(mock_roomba.master_state) + dockless_state["state"]["reported"]["dock"] = {} + + with patch("homeassistant.components.roomba.PLATFORMS", [Platform.SENSOR]): + for blid, state in ( + ("blid_docked", docked_state), + ("blid_dockless", dockless_state), + ): + mock_roomba.master_state = state + config_entry = _config_entry(blid) + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert len(_dock_tank_level_entities(hass)) == 1 From 4f98b35340c1f55f4afada602df0764f26db6d5a Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Sun, 6 Sep 2026 23:27:22 -0700 Subject: [PATCH 03/20] Bump gcal-sync to 9.1.1 (#181499) Co-authored-by: Home Assistant Developer --- homeassistant/components/google/manifest.json | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/google/manifest.json b/homeassistant/components/google/manifest.json index 46886ac36534f0..22fc0bdd722233 100644 --- a/homeassistant/components/google/manifest.json +++ b/homeassistant/components/google/manifest.json @@ -8,5 +8,5 @@ "integration_type": "service", "iot_class": "cloud_polling", "loggers": ["googleapiclient"], - "requirements": ["gcal-sync==9.1.0", "oauth2client==4.1.3", "ical==14.1.1"] + "requirements": ["gcal-sync==9.1.1", "oauth2client==4.1.3", "ical==14.1.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index 1d2eb3766f4252..ea109d84b9e162 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1109,7 +1109,7 @@ gatus-api==1.2.0 gazetteer-matcher==1.1.0 # homeassistant.components.google -gcal-sync==9.1.0 +gcal-sync==9.1.1 # homeassistant.components.aladdin_connect genie-partner-sdk==1.0.11 From ecd2f8e0726583b354999a550c7c89e14f9672fd Mon Sep 17 00:00:00 2001 From: tronikos Date: Sun, 6 Sep 2026 23:30:15 -0700 Subject: [PATCH 04/20] Fix small issues in Google Weather (#181483) --- .../components/google_weather/__init__.py | 30 ++++++++++++------- .../components/google_weather/icons.json | 3 -- .../components/google_weather/sensor.py | 1 - 3 files changed, 20 insertions(+), 14 deletions(-) diff --git a/homeassistant/components/google_weather/__init__.py b/homeassistant/components/google_weather/__init__.py index 54d5c6c2bd22ad..0b8bff8f25ec44 100644 --- a/homeassistant/components/google_weather/__init__.py +++ b/homeassistant/components/google_weather/__init__.py @@ -31,6 +31,8 @@ async def async_setup_entry( api_key=entry.data[CONF_API_KEY], referrer=entry.data.get(CONF_REFERRER), language_code=hass.config.language, + # The entities report native values in metric units. + units_system="METRIC", ) subentries_runtime_data: dict[str, GoogleWeatherSubEntryRuntimeData] = {} for subentry in entry.subentries.values(): @@ -46,16 +48,24 @@ async def async_setup_entry( ), ) subentries_runtime_data[subentry.subentry_id] = subentry_runtime_data - tasks = [ - coro - for subentry_runtime_data in subentries_runtime_data.values() - for coro in ( - subentry_runtime_data.coordinator_observation.async_config_entry_first_refresh(), - subentry_runtime_data.coordinator_daily_forecast.async_config_entry_first_refresh(), - subentry_runtime_data.coordinator_hourly_forecast.async_config_entry_first_refresh(), - ) - ] - await asyncio.gather(*tasks) + # Wait for every refresh to settle before failing, so that no refresh outlives + # a setup that did not complete. Exceptions are re-raised as-is, so that + # ConfigEntryNotReady and ConfigEntryAuthFailed keep their meaning. + results = await asyncio.gather( + *( + coordinator.async_config_entry_first_refresh() + for subentry_runtime_data in subentries_runtime_data.values() + for coordinator in ( + subentry_runtime_data.coordinator_observation, + subentry_runtime_data.coordinator_daily_forecast, + subentry_runtime_data.coordinator_hourly_forecast, + ) + ), + return_exceptions=True, + ) + for result in results: + if isinstance(result, BaseException): + raise result entry.runtime_data = GoogleWeatherRuntimeData( api=api, subentries_runtime_data=subentries_runtime_data, diff --git a/homeassistant/components/google_weather/icons.json b/homeassistant/components/google_weather/icons.json index b8927a1578f4af..07b4e989ff7fcd 100644 --- a/homeassistant/components/google_weather/icons.json +++ b/homeassistant/components/google_weather/icons.json @@ -7,9 +7,6 @@ "precipitation_probability": { "default": "mdi:weather-rainy" }, - "precipitation_qpf": { - "default": "mdi:cup-water" - }, "thunderstorm_probability": { "default": "mdi:weather-lightning" }, diff --git a/homeassistant/components/google_weather/sensor.py b/homeassistant/components/google_weather/sensor.py index 5e19f1d8d8fc59..3992065943226a 100644 --- a/homeassistant/components/google_weather/sensor.py +++ b/homeassistant/components/google_weather/sensor.py @@ -198,7 +198,6 @@ async def async_setup_entry( ( GoogleWeatherSensor(coordinator, subentry, description) for description in SENSOR_TYPES - if description.value_fn(coordinator.data) is not None ), config_subentry_id=subentry.subentry_id, ) From b990c65a5cb901667c31f4cbdded5cffa1105b32 Mon Sep 17 00:00:00 2001 From: tronikos Date: Sun, 6 Sep 2026 23:30:46 -0700 Subject: [PATCH 05/20] Bump python-google-weather-api to 0.0.7 (#181482) --- .../components/google_weather/coordinator.py | 7 +- .../components/google_weather/manifest.json | 2 +- requirements_all.txt | 2 +- .../snapshots/test_diagnostics.ambr | 178 +++++++++--------- tests/components/google_weather/test_init.py | 17 ++ 5 files changed, 114 insertions(+), 92 deletions(-) diff --git a/homeassistant/components/google_weather/coordinator.py b/homeassistant/components/google_weather/coordinator.py index 8efb6f10e36211..444d2d8d66489d 100644 --- a/homeassistant/components/google_weather/coordinator.py +++ b/homeassistant/components/google_weather/coordinator.py @@ -3,6 +3,7 @@ from collections.abc import Awaitable, Callable from dataclasses import dataclass from datetime import timedelta +from functools import partial import logging from typing import TypeVar, override @@ -28,6 +29,10 @@ _LOGGER = logging.getLogger(__name__) +# The API returns at most 24 hourly records per request, so asking for more than +# that costs an extra request per update. Keep it to a single page. +HOURLY_FORECAST_HOURS = 24 + T = TypeVar( "T", bound=( @@ -182,5 +187,5 @@ def __init__( subentry, "hourly weather forecast", timedelta(hours=1), - api.async_get_hourly_forecast, + partial(api.async_get_hourly_forecast, hours=HOURLY_FORECAST_HOURS), ) diff --git a/homeassistant/components/google_weather/manifest.json b/homeassistant/components/google_weather/manifest.json index e7ec2e05563d1e..d47c56f552d281 100644 --- a/homeassistant/components/google_weather/manifest.json +++ b/homeassistant/components/google_weather/manifest.json @@ -8,5 +8,5 @@ "iot_class": "cloud_polling", "loggers": ["google_weather_api"], "quality_scale": "platinum", - "requirements": ["python-google-weather-api==0.0.6"] + "requirements": ["python-google-weather-api==0.0.7"] } diff --git a/requirements_all.txt b/requirements_all.txt index ea109d84b9e162..8531638dc3cb82 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2729,7 +2729,7 @@ python-gitlab==1.6.0 python-google-drive-api==0.1.0 # homeassistant.components.google_weather -python-google-weather-api==0.0.6 +python-google-weather-api==0.0.7 # homeassistant.components.analytics_insights python-homeassistant-analytics==0.9.0 diff --git a/tests/components/google_weather/snapshots/test_diagnostics.ambr b/tests/components/google_weather/snapshots/test_diagnostics.ambr index 87f707a7bf32ef..d8e92ef683c1c6 100644 --- a/tests/components/google_weather/snapshots/test_diagnostics.ambr +++ b/tests/components/google_weather/snapshots/test_diagnostics.ambr @@ -36,14 +36,14 @@ 'subentries': dict({ 'home-subentry-id': dict({ 'daily_forecast_data': dict({ - 'forecast_days': list([ + 'forecastDays': list([ dict({ - 'daytime_forecast': dict({ - 'cloud_cover': 53, - 'ice_thickness': None, + 'daytimeForecast': dict({ + 'cloudCover': 53, + 'iceThickness': None, 'interval': dict({ - 'end_time': '2025-02-11T03:00:00Z', - 'start_time': '2025-02-10T15:00:00Z', + 'endTime': '2025-02-11T03:00:00Z', + 'startTime': '2025-02-10T15:00:00Z', }), 'precipitation': dict({ 'probability': dict({ @@ -54,17 +54,17 @@ 'quantity': 0.0, 'unit': 'MILLIMETERS', }), - 'snow_qpf': None, + 'snowQpf': None, }), - 'relative_humidity': 54, - 'thunderstorm_probability': 0, - 'uv_index': 3, - 'weather_condition': dict({ + 'relativeHumidity': 54, + 'thunderstormProbability': 0, + 'uvIndex': 3, + 'weatherCondition': dict({ 'description': dict({ - 'language_code': 'en', + 'languageCode': 'en', 'text': 'Partly sunny', }), - 'icon_base_uri': 'https://maps.gstatic.com/weather/v1/party_cloudy', + 'iconBaseUri': 'https://maps.gstatic.com/weather/v1/party_cloudy', 'type': 'PARTLY_CLOUDY', }), 'wind': dict({ @@ -82,54 +82,54 @@ }), }), }), - 'display_date': dict({ + 'displayDate': dict({ 'day': 10, 'month': 2, 'year': 2025, }), - 'feels_like_max_temperature': dict({ + 'feelsLikeMaxTemperature': dict({ 'degrees': 13.3, 'unit': 'CELSIUS', }), - 'feels_like_min_temperature': dict({ + 'feelsLikeMinTemperature': dict({ 'degrees': 1.5, 'unit': 'CELSIUS', }), - 'ice_thickness': dict({ + 'iceThickness': dict({ 'thickness': 0.0, 'unit': 'MILLIMETERS', }), 'interval': dict({ - 'end_time': '2025-02-11T15:00:00Z', - 'start_time': '2025-02-10T15:00:00Z', + 'endTime': '2025-02-11T15:00:00Z', + 'startTime': '2025-02-10T15:00:00Z', }), - 'max_heat_index': dict({ + 'maxHeatIndex': dict({ 'degrees': 13.3, 'unit': 'CELSIUS', }), - 'max_temperature': dict({ + 'maxTemperature': dict({ 'degrees': 13.3, 'unit': 'CELSIUS', }), - 'min_temperature': dict({ + 'minTemperature': dict({ 'degrees': 1.5, 'unit': 'CELSIUS', }), - 'moon_events': dict({ - 'moon_phase': 'WAXING_GIBBOUS', - 'moonrise_times': list([ + 'moonEvents': dict({ + 'moonPhase': 'WAXING_GIBBOUS', + 'moonriseTimes': list([ '2025-02-10T23:54:17.713157984Z', ]), - 'moonset_times': list([ + 'moonsetTimes': list([ '2025-02-10T14:13:58.625181191Z', ]), }), - 'nighttime_forecast': dict({ - 'cloud_cover': 70, - 'ice_thickness': None, + 'nighttimeForecast': dict({ + 'cloudCover': 70, + 'iceThickness': None, 'interval': dict({ - 'end_time': '2025-02-11T15:00:00Z', - 'start_time': '2025-02-11T03:00:00Z', + 'endTime': '2025-02-11T15:00:00Z', + 'startTime': '2025-02-11T03:00:00Z', }), 'precipitation': dict({ 'probability': dict({ @@ -140,17 +140,17 @@ 'quantity': 0.0, 'unit': 'MILLIMETERS', }), - 'snow_qpf': None, + 'snowQpf': None, }), - 'relative_humidity': 85, - 'thunderstorm_probability': 0, - 'uv_index': 0, - 'weather_condition': dict({ + 'relativeHumidity': 85, + 'thunderstormProbability': 0, + 'uvIndex': 0, + 'weatherCondition': dict({ 'description': dict({ - 'language_code': 'en', + 'languageCode': 'en', 'text': 'Partly cloudy', }), - 'icon_base_uri': 'https://maps.gstatic.com/weather/v1/partly_clear', + 'iconBaseUri': 'https://maps.gstatic.com/weather/v1/partly_clear', 'type': 'PARTLY_CLOUDY', }), 'wind': dict({ @@ -168,57 +168,57 @@ }), }), }), - 'sun_events': dict({ - 'sunrise_time': '2025-02-10T15:02:35.703929582Z', - 'sunset_time': '2025-02-11T01:43:00.762932858Z', + 'sunEvents': dict({ + 'sunriseTime': '2025-02-10T15:02:35.703929582Z', + 'sunsetTime': '2025-02-11T01:43:00.762932858Z', }), }), ]), - 'next_page_token': None, - 'time_zone': dict({ + 'nextPageToken': None, + 'timeZone': dict({ 'id': 'America/Los_Angeles', 'version': None, }), }), 'hourly_forecast_data': dict({ - 'forecast_hours': list([ + 'forecastHours': list([ dict({ - 'air_pressure': dict({ - 'mean_sea_level_millibars': 1019.13, + 'airPressure': dict({ + 'meanSeaLevelMillibars': 1019.13, }), - 'cloud_cover': 0, - 'dew_point': dict({ + 'cloudCover': 0, + 'dewPoint': dict({ 'degrees': 2.7, 'unit': 'CELSIUS', }), - 'display_date_time': dict({ + 'displayDateTime': dict({ 'day': 5, 'hours': 15, 'minutes': None, 'month': 2, 'nanos': None, 'seconds': None, - 'time_zone': None, - 'utc_offset': '-28800s', + 'timeZone': None, + 'utcOffset': '-28800s', 'year': 2025, }), - 'feels_like_temperature': dict({ + 'feelsLikeTemperature': dict({ 'degrees': 12.0, 'unit': 'CELSIUS', }), - 'heat_index': dict({ + 'heatIndex': dict({ 'degrees': 12.7, 'unit': 'CELSIUS', }), - 'ice_thickness': dict({ + 'iceThickness': dict({ 'thickness': 0.0, 'unit': 'MILLIMETERS', }), 'interval': dict({ - 'end_time': '2025-02-06T00:00:00Z', - 'start_time': '2025-02-05T23:00:00Z', + 'endTime': '2025-02-06T00:00:00Z', + 'startTime': '2025-02-05T23:00:00Z', }), - 'is_daytime': True, + 'isDaytime': True, 'precipitation': dict({ 'probability': dict({ 'percent': 0, @@ -228,28 +228,28 @@ 'quantity': 0.0, 'unit': 'MILLIMETERS', }), - 'snow_qpf': None, + 'snowQpf': None, }), - 'relative_humidity': 51, + 'relativeHumidity': 51, 'temperature': dict({ 'degrees': 12.7, 'unit': 'CELSIUS', }), - 'thunderstorm_probability': 0, - 'uv_index': 1, + 'thunderstormProbability': 0, + 'uvIndex': 1, 'visibility': dict({ 'distance': 16.0, 'unit': 'KILOMETERS', }), - 'weather_condition': dict({ + 'weatherCondition': dict({ 'description': dict({ - 'language_code': 'en', + 'languageCode': 'en', 'text': 'Sunny', }), - 'icon_base_uri': 'https://maps.gstatic.com/weather/v1/sunny', + 'iconBaseUri': 'https://maps.gstatic.com/weather/v1/sunny', 'type': 'CLEAR', }), - 'wet_bulb_temperature': dict({ + 'wetBulbTemperature': dict({ 'degrees': 7.7, 'unit': 'CELSIUS', }), @@ -267,29 +267,29 @@ 'value': 10.0, }), }), - 'wind_chill': dict({ + 'windChill': dict({ 'degrees': 12.0, 'unit': 'CELSIUS', }), }), ]), - 'next_page_token': None, - 'time_zone': dict({ + 'nextPageToken': None, + 'timeZone': dict({ 'id': 'America/Los_Angeles', 'version': None, }), }), 'observation_data': dict({ - 'air_pressure': dict({ - 'mean_sea_level_millibars': 1019.16, + 'airPressure': dict({ + 'meanSeaLevelMillibars': 1019.16, }), - 'cloud_cover': 0, - 'current_conditions_history': dict({ - 'max_temperature': dict({ + 'cloudCover': 0, + 'currentConditionsHistory': dict({ + 'maxTemperature': dict({ 'degrees': 14.3, 'unit': 'CELSIUS', }), - 'min_temperature': dict({ + 'minTemperature': dict({ 'degrees': 3.7, 'unit': 'CELSIUS', }), @@ -297,25 +297,25 @@ 'quantity': 0.0, 'unit': 'MILLIMETERS', }), - 'temperature_change': dict({ + 'temperatureChange': dict({ 'degrees': -0.6, 'unit': 'CELSIUS', }), }), - 'current_time': '2025-01-28T22:04:12.025273178Z', - 'dew_point': dict({ + 'currentTime': '2025-01-28T22:04:12.025273178Z', + 'dewPoint': dict({ 'degrees': 1.1, 'unit': 'CELSIUS', }), - 'feels_like_temperature': dict({ + 'feelsLikeTemperature': dict({ 'degrees': 13.1, 'unit': 'CELSIUS', }), - 'heat_index': dict({ + 'heatIndex': dict({ 'degrees': 13.7, 'unit': 'CELSIUS', }), - 'is_daytime': True, + 'isDaytime': True, 'precipitation': dict({ 'probability': dict({ 'percent': 0, @@ -325,29 +325,29 @@ 'quantity': 0.0, 'unit': 'MILLIMETERS', }), - 'snow_qpf': None, + 'snowQpf': None, }), - 'relative_humidity': 42, + 'relativeHumidity': 42, 'temperature': dict({ 'degrees': 13.7, 'unit': 'CELSIUS', }), - 'thunderstorm_probability': 0, - 'time_zone': dict({ + 'thunderstormProbability': 0, + 'timeZone': dict({ 'id': 'America/Los_Angeles', 'version': None, }), - 'uv_index': 1, + 'uvIndex': 1, 'visibility': dict({ 'distance': 16.0, 'unit': 'KILOMETERS', }), - 'weather_condition': dict({ + 'weatherCondition': dict({ 'description': dict({ - 'language_code': 'en', + 'languageCode': 'en', 'text': 'Sunny', }), - 'icon_base_uri': 'https://maps.gstatic.com/weather/v1/sunny', + 'iconBaseUri': 'https://maps.gstatic.com/weather/v1/sunny', 'type': 'CLEAR', }), 'wind': dict({ @@ -364,7 +364,7 @@ 'value': 8.0, }), }), - 'wind_chill': dict({ + 'windChill': dict({ 'degrees': 13.1, 'unit': 'CELSIUS', }), diff --git a/tests/components/google_weather/test_init.py b/tests/components/google_weather/test_init.py index 6bc1d3ccc89376..dd329f1ad313e4 100644 --- a/tests/components/google_weather/test_init.py +++ b/tests/components/google_weather/test_init.py @@ -29,6 +29,23 @@ async def test_async_setup_entry( assert state.state == "sunny" +async def test_hourly_forecast_fits_in_one_request( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_google_weather_api: AsyncMock, +) -> None: + """Test the hourly forecast asks for no more than one page of records. + + The API returns at most 24 hourly records per request, so asking for more + costs an extra request against the quota on every update. + """ + await hass.config_entries.async_setup(mock_config_entry.entry_id) + + mock_google_weather_api.async_get_hourly_forecast.assert_called_once_with( + 10.1, 20.1, hours=24 + ) + + @pytest.mark.parametrize( "failing_api_method", [ From 8bc72323355ee29a5654eb230427a3bbf36d2794 Mon Sep 17 00:00:00 2001 From: tronikos Date: Sun, 6 Sep 2026 23:31:27 -0700 Subject: [PATCH 06/20] Bump python-google-drive-api to 0.2.0 (#181477) --- homeassistant/components/google_drive/manifest.json | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/google_drive/manifest.json b/homeassistant/components/google_drive/manifest.json index 6b199a5d3ebaf3..975cce727904dd 100644 --- a/homeassistant/components/google_drive/manifest.json +++ b/homeassistant/components/google_drive/manifest.json @@ -10,5 +10,5 @@ "iot_class": "cloud_polling", "loggers": ["google_drive_api"], "quality_scale": "platinum", - "requirements": ["python-google-drive-api==0.1.0"] + "requirements": ["python-google-drive-api==0.2.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 8531638dc3cb82..3a1a0bed9c0176 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2726,7 +2726,7 @@ python-gc100==1.0.3a0 python-gitlab==1.6.0 # homeassistant.components.google_drive -python-google-drive-api==0.1.0 +python-google-drive-api==0.2.0 # homeassistant.components.google_weather python-google-weather-api==0.0.7 From efac25fdf1e161462e63a125acbe1d0bed464727 Mon Sep 17 00:00:00 2001 From: tronikos Date: Sun, 6 Sep 2026 23:32:08 -0700 Subject: [PATCH 07/20] Make Google Drive backup listing resilient to unreadable metadata (#181478) --- homeassistant/components/google_drive/api.py | 84 +++++++++--- .../components/google_drive/backup.py | 14 +- .../google_drive/snapshots/test_backup.ambr | 18 +-- .../snapshots/test_diagnostic.ambr | 2 +- tests/components/google_drive/test_backup.py | 126 ++++++++++++++++++ .../google_drive/test_diagnostic.py | 1 + tests/components/google_drive/test_sensor.py | 34 +++-- 7 files changed, 236 insertions(+), 43 deletions(-) diff --git a/homeassistant/components/google_drive/api.py b/homeassistant/components/google_drive/api.py index f6dfaa1b76bc98..10d51a5fa29228 100644 --- a/homeassistant/components/google_drive/api.py +++ b/homeassistant/components/google_drive/api.py @@ -34,6 +34,43 @@ class StorageQuotaData: usage_in_trash: int +def _invalid_metadata_reason(metadata: Any) -> str | None: + """Return why decoded metadata cannot be used, or None if it can. + + AgentBackup.from_dict does not enforce its annotations, so the types the + backup manager relies on are checked here, before it is built: the manager + uses backup_id as a dict key and calls extra_metadata.get() on every backup. + """ + if not isinstance(metadata, dict): + return "description is not a JSON object" + if not isinstance(metadata.get("backup_id"), str): + return "backup_id is not a string" + if not isinstance(metadata.get("extra_metadata"), dict): + return "extra_metadata is not a dictionary" + return None + + +def _parse_backup_metadata(file: dict[str, Any]) -> AgentBackup | None: + """Return the backup a Drive file describes, or None if it cannot be read. + + The metadata lives in the file description, which the user can edit or clear + from the Google Drive UI. One unreadable file should not hide the others. + """ + reason: object + try: + metadata = json.loads(file["description"]) + if (reason := _invalid_metadata_reason(metadata)) is None: + return AgentBackup.from_dict(metadata) + except (KeyError, TypeError, ValueError) as err: + reason = err + _LOGGER.warning( + "Ignoring backup file %s: its description is not valid backup metadata: %s", + file.get("id", "?"), + reason, + ) + return None + + class AsyncConfigEntryAuth(AbstractAuth): """Provide Google Drive authentication tied to an OAuth2 based config entry.""" @@ -195,42 +232,49 @@ async def async_upload_backup( backup_metadata["name"], ) - async def async_list_backups(self) -> list[AgentBackup]: - """List backups.""" - query = " and ".join( + def _backup_query(self, *extra: str) -> str: + """Return a query matching the backups of this Home Assistant instance.""" + return " and ".join( [ "properties has { key='home_assistant' and value='backup' }", "properties has { key='instance_id'" f" and value='{self._ha_instance_id}' }}", "trashed=false", + *extra, ] ) + + async def async_list_backups(self) -> list[AgentBackup]: + """List backups.""" res = await self._api.list_files( - params={"q": query, "fields": "files(description)"} + params={"q": self._backup_query(), "fields": "files(id,description)"} ) - backups = [] - for file in res["files"]: - backup = AgentBackup.from_dict(json.loads(file["description"])) - backups.append(backup) - return backups + return [ + backup + for file in res["files"] + if (backup := _parse_backup_metadata(file)) is not None + ] async def async_get_size_of_all_backups(self) -> int: """Get size of all backups.""" - backups = await self.async_list_backups() - - return sum(backup.size for backup in backups) + # Ask Drive for the size of each file instead of adding up the sizes stored + # in the metadata, which would mean downloading and parsing every backup's + # description just to update a sensor. + res = await self._api.list_files( + params={"q": self._backup_query(), "fields": "files(size)"} + ) + return sum(int(file["size"]) for file in res["files"] if "size" in file) async def async_get_backup_file_id(self, backup_id: str) -> str | None: """Get file_id of backup if it exists.""" - query = " and ".join( - [ - "properties has { key='home_assistant' and value='backup' }", - "properties has { key='instance_id'" - f" and value='{self._ha_instance_id}' }}", - f"properties has {{ key='backup_id' and value='{backup_id}' }}", - ] + res = await self._api.list_files( + params={ + "q": self._backup_query( + f"properties has {{ key='backup_id' and value='{backup_id}' }}" + ), + "fields": "files(id)", + } ) - res = await self._api.list_files(params={"q": query, "fields": "files(id)"}) for file in res["files"]: return str(file["id"]) return None diff --git a/homeassistant/components/google_drive/backup.py b/homeassistant/components/google_drive/backup.py index 8f50d5208304f5..3be2972cf29f44 100644 --- a/homeassistant/components/google_drive/backup.py +++ b/homeassistant/components/google_drive/backup.py @@ -85,16 +85,24 @@ async def async_upload_backup( :param backup: Metadata about the backup that should be uploaded. """ + bytes_uploaded = 0 + @wraps(open_stream) async def wrapped_open_stream() -> AsyncIterator[bytes]: stream = await open_stream() async def _progress_stream() -> AsyncIterator[bytes]: - bytes_uploaded = 0 + nonlocal bytes_uploaded + position = 0 async for chunk in stream: yield chunk - bytes_uploaded += len(chunk) - on_progress(bytes_uploaded=bytes_uploaded) + position += len(chunk) + # A retried upload reopens the stream from the beginning and + # skips whatever the server already received, so only report + # progress once it passes what was previously uploaded. + if position > bytes_uploaded: + bytes_uploaded = position + on_progress(bytes_uploaded=bytes_uploaded) return _progress_stream() diff --git a/tests/components/google_drive/snapshots/test_backup.ambr b/tests/components/google_drive/snapshots/test_backup.ambr index d783dad34d04b4..c9e31e3b53beec 100644 --- a/tests/components/google_drive/snapshots/test_backup.ambr +++ b/tests/components/google_drive/snapshots/test_backup.ambr @@ -38,7 +38,7 @@ ), dict({ 'params': dict({ - 'fields': 'files(description)', + 'fields': 'files(size)', 'q': "properties has { key='home_assistant' and value='backup' } and properties has { key='instance_id' and value='0a123c' } and trashed=false", }), }), @@ -50,7 +50,7 @@ dict({ 'params': dict({ 'fields': 'files(id)', - 'q': "properties has { key='home_assistant' and value='backup' } and properties has { key='instance_id' and value='0a123c' } and properties has { key='backup_id' and value='test-backup' }", + 'q': "properties has { key='home_assistant' and value='backup' } and properties has { key='instance_id' and value='0a123c' } and trashed=false and properties has { key='backup_id' and value='test-backup' }", }), }), ), @@ -103,7 +103,7 @@ ), dict({ 'params': dict({ - 'fields': 'files(description)', + 'fields': 'files(size)', 'q': "properties has { key='home_assistant' and value='backup' } and properties has { key='instance_id' and value='0a123c' } and trashed=false", }), }), @@ -114,7 +114,7 @@ ), dict({ 'params': dict({ - 'fields': 'files(description)', + 'fields': 'files(id,description)', 'q': "properties has { key='home_assistant' and value='backup' } and properties has { key='instance_id' and value='0a123c' } and trashed=false", }), }), @@ -126,7 +126,7 @@ dict({ 'params': dict({ 'fields': 'files(id)', - 'q': "properties has { key='home_assistant' and value='backup' } and properties has { key='instance_id' and value='0a123c' } and properties has { key='backup_id' and value='test-backup' }", + 'q': "properties has { key='home_assistant' and value='backup' } and properties has { key='instance_id' and value='0a123c' } and trashed=false and properties has { key='backup_id' and value='test-backup' }", }), }), ), @@ -186,7 +186,7 @@ ), dict({ 'params': dict({ - 'fields': 'files(description)', + 'fields': 'files(size)', 'q': "properties has { key='home_assistant' and value='backup' } and properties has { key='instance_id' and value='0a123c' } and trashed=false", }), }), @@ -197,7 +197,7 @@ ), dict({ 'params': dict({ - 'fields': 'files(description)', + 'fields': 'files(id,description)', 'q': "properties has { key='home_assistant' and value='backup' } and properties has { key='instance_id' and value='0a123c' } and trashed=false", }), }), @@ -243,7 +243,7 @@ ), dict({ 'params': dict({ - 'fields': 'files(description)', + 'fields': 'files(size)', 'q': "properties has { key='home_assistant' and value='backup' } and properties has { key='instance_id' and value='0a123c' } and trashed=false", }), }), @@ -329,7 +329,7 @@ ), dict({ 'params': dict({ - 'fields': 'files(description)', + 'fields': 'files(size)', 'q': "properties has { key='home_assistant' and value='backup' } and properties has { key='instance_id' and value='0a123c' } and trashed=false", }), }), diff --git a/tests/components/google_drive/snapshots/test_diagnostic.ambr b/tests/components/google_drive/snapshots/test_diagnostic.ambr index d88e3da02bd0e9..eb3c732db4c7e5 100644 --- a/tests/components/google_drive/snapshots/test_diagnostic.ambr +++ b/tests/components/google_drive/snapshots/test_diagnostic.ambr @@ -41,7 +41,7 @@ }), }), 'coordinator_data': dict({ - 'all_backups_size': 104857600.0, + 'all_backups_size': 104857600, 'storage_quota': dict({ 'limit': 10737418240, 'usage': 5368709120, diff --git a/tests/components/google_drive/test_backup.py b/tests/components/google_drive/test_backup.py index 48e2b72878a35a..85e6a83ae59fe7 100644 --- a/tests/components/google_drive/test_backup.py +++ b/tests/components/google_drive/test_backup.py @@ -73,6 +73,23 @@ async def consume_stream( pass +async def consume_stream_twice( + file_metadata: Any, + open_stream: Any, + *args: Any, + **kwargs: Any, +) -> None: + """Consume the stream twice, like a resumable upload that had to retry. + + A retried upload reopens the stream from the beginning and skips whatever + the server already received. + """ + for _ in range(2): + stream = await open_stream() + async for _ in stream: + pass + + @pytest.fixture(autouse=True) async def setup_integration( hass: HomeAssistant, @@ -143,6 +160,84 @@ async def test_agents_list_backups( assert [tuple(mock_call) for mock_call in mock_api.mock_calls] == snapshot +async def test_agents_list_backups_ignores_unreadable_metadata( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + mock_api: MagicMock, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test that a backup whose description cannot be read is skipped. + + The description is editable from the Google Drive UI, so one unreadable + file must not hide the others. + """ + mock_api.list_files = AsyncMock( + return_value={ + "files": [ + {"id": "no description at all"}, + {"id": "not json", "description": "cleared by the user"}, + {"id": "not backup metadata", "description": '{"foo": "bar"}'}, + {"description": json.dumps(TEST_AGENT_BACKUP.as_dict())}, + ] + } + ) + + client = await hass_ws_client(hass) + await client.send_json_auto_id({"type": "backup/info"}) + response = await client.receive_json() + + assert response["success"] + assert response["result"]["agent_errors"] == {} + assert response["result"]["backups"] == [TEST_AGENT_BACKUP_RESULT] + assert "Ignoring backup file no description at all" in caplog.text + assert "Ignoring backup file not json" in caplog.text + assert "Ignoring backup file not backup metadata" in caplog.text + + +@pytest.mark.parametrize( + ("field", "value"), + [ + # The backup manager calls extra_metadata.get() on every backup. + ("extra_metadata", []), + # The backup manager uses backup_id as a dict key. + ("backup_id", ["not a string"]), + ], +) +async def test_agents_list_backups_ignores_wrong_typed_metadata( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + mock_api: MagicMock, + caplog: pytest.LogCaptureFixture, + field: str, + value: Any, +) -> None: + """Test that metadata which decodes but has the wrong types is skipped. + + AgentBackup.from_dict does not enforce its annotations, so such a backup is + only rejected once the backup manager uses it. + """ + wrong_types = TEST_AGENT_BACKUP.as_dict() + wrong_types[field] = value + mock_api.list_files = AsyncMock( + return_value={ + "files": [ + {"id": "wrong types", "description": json.dumps(wrong_types)}, + {"description": json.dumps(TEST_AGENT_BACKUP.as_dict())}, + ] + } + ) + + client = await hass_ws_client(hass) + await client.send_json_auto_id({"type": "backup/info"}) + response = await client.receive_json() + + assert response["success"] + assert response["result"]["agent_errors"] == {} + assert response["result"]["backups"] == [TEST_AGENT_BACKUP_RESULT] + assert "Ignoring backup file wrong types" in caplog.text + assert f"{field} is not a" in caplog.text + + async def test_agents_list_backups_fail( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, @@ -399,6 +494,37 @@ async def stream() -> AsyncIterator[bytes]: assert progress_calls == [6, 12] +async def test_agents_upload_progress_does_not_go_backwards_on_retry( + hass: HomeAssistant, + mock_api: MagicMock, +) -> None: + """Test agent upload progress is not reported twice when the upload retries.""" + mock_api.resumable_upload_file = AsyncMock(side_effect=consume_stream_twice) + + entries = hass.config_entries.async_entries(DOMAIN) + agent = GoogleDriveBackupAgent(entries[0]) + + progress_calls = [] + + def on_progress(*, bytes_uploaded: int, **kwargs: Any) -> None: + progress_calls.append(bytes_uploaded) + + async def open_stream() -> AsyncIterator[bytes]: + async def stream() -> AsyncIterator[bytes]: + yield b"chunk1" + yield b"chunk2" + + return stream() + + await agent.async_upload_backup( + open_stream=open_stream, + backup=TEST_AGENT_BACKUP, + on_progress=on_progress, + ) + + assert progress_calls == [6, 12] + + async def test_agents_upload_fail( hass: HomeAssistant, hass_client: ClientSessionGenerator, diff --git a/tests/components/google_drive/test_diagnostic.py b/tests/components/google_drive/test_diagnostic.py index 006df85b51527b..e2d0c83f84dcb3 100644 --- a/tests/components/google_drive/test_diagnostic.py +++ b/tests/components/google_drive/test_diagnostic.py @@ -35,6 +35,7 @@ async def test_entry_diagnostics( "id": "HA folder ID", "name": "HA folder name", "description": json.dumps(mock_agent_backup.as_dict()), + "size": str(int(mock_agent_backup.size)), } ] } diff --git a/tests/components/google_drive/test_sensor.py b/tests/components/google_drive/test_sensor.py index 6e9d959cfbcd5f..dab2aa6f2edf99 100644 --- a/tests/components/google_drive/test_sensor.py +++ b/tests/components/google_drive/test_sensor.py @@ -1,6 +1,5 @@ """Tests for the Google Drive sensor platform.""" -import json from unittest.mock import AsyncMock, MagicMock from freezegun.api import FrozenDateTimeFactory @@ -123,15 +122,7 @@ async def test_calculate_backups_size( assert state.state == "0.0" mock_api.list_files = AsyncMock( - return_value={ - "files": [ - { - "id": "HA folder ID", - "name": "HA folder name", - "description": json.dumps(mock_agent_backup.as_dict()), - } - ] - } + return_value={"files": [{"size": str(int(mock_agent_backup.size))}]} ) freezer.tick(SCAN_INTERVAL) async_fire_time_changed(hass) @@ -141,3 +132,26 @@ async def test_calculate_backups_size( state := hass.states.get("sensor.testuser_domain_com_total_size_of_backups") ) assert state.state == "100.0" + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_calculate_backups_size_ignores_files_without_size( + hass: HomeAssistant, + mock_api: MagicMock, + config_entry: MockConfigEntry, + freezer: FrozenDateTimeFactory, +) -> None: + """Test that a file Google Drive reports no size for is skipped.""" + await setup_integration(hass, config_entry) + + mock_api.list_files = AsyncMock( + return_value={"files": [{"size": "1048576"}, {"id": "no size reported"}]} + ) + freezer.tick(SCAN_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert ( + state := hass.states.get("sensor.testuser_domain_com_total_size_of_backups") + ) + assert state.state == "1.0" From b6dea9c5fc0cc08dac12835a48e8b0f31991db21 Mon Sep 17 00:00:00 2001 From: Denis Shulyaka Date: Mon, 7 Sep 2026 09:32:22 +0300 Subject: [PATCH 08/20] Fix OpenAI prompt caching (#181472) --- homeassistant/components/openai_conversation/entity.py | 1 + tests/components/openai_conversation/test_ai_task.py | 4 ++++ tests/components/openai_conversation/test_conversation.py | 5 +++-- 3 files changed, 8 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/openai_conversation/entity.py b/homeassistant/components/openai_conversation/entity.py index aa0d05ae98e97c..8402b3d120585d 100644 --- a/homeassistant/components/openai_conversation/entity.py +++ b/homeassistant/components/openai_conversation/entity.py @@ -517,6 +517,7 @@ async def _async_handle_chat_log( # noqa: C901 input=messages, max_output_tokens=options.get(CONF_MAX_TOKENS, RECOMMENDED_MAX_TOKENS), user=chat_log.conversation_id, + prompt_cache_key=self.subentry.subentry_id, service_tier=options.get(CONF_SERVICE_TIER, RECOMMENDED_SERVICE_TIER), store=options.get(CONF_STORE_RESPONSES, RECOMMENDED_STORE_RESPONSES), stream=True, diff --git a/tests/components/openai_conversation/test_ai_task.py b/tests/components/openai_conversation/test_ai_task.py index dc2d70265766cf..b123dc497d4afa 100644 --- a/tests/components/openai_conversation/test_ai_task.py +++ b/tests/components/openai_conversation/test_ai_task.py @@ -66,6 +66,10 @@ async def test_generate_data( assert result.data == "The test data" assert mock_create_stream.call_args is not None assert mock_create_stream.call_args.kwargs["store"] is expected_store + assert ( + mock_create_stream.call_args.kwargs["prompt_cache_key"] + == ai_task_entry.subentry_id + ) @pytest.mark.usefixtures("mock_init_component") diff --git a/tests/components/openai_conversation/test_conversation.py b/tests/components/openai_conversation/test_conversation.py index 933e57bc7d1b76..199ccbb23b00a4 100644 --- a/tests/components/openai_conversation/test_conversation.py +++ b/tests/components/openai_conversation/test_conversation.py @@ -823,13 +823,13 @@ async def test_flex_tier_retry( @pytest.mark.parametrize( "subentry_options", [{CONF_CHAT_MODEL: "gpt-5.6-sol", CONF_PRO_MODE: True}] ) +@pytest.mark.usefixtures("mock_init_component") async def test_model_args( hass: HomeAssistant, mock_config_entry: MockConfigEntry, - mock_init_component, mock_create_stream: AsyncMock, snapshot: SnapshotAssertion, - subentry_options: dict, + subentry_options: dict[str, str | bool], ) -> None: """Test model arguments for various configuration.""" @@ -860,4 +860,5 @@ async def test_model_args( model_args = mock_create_stream.call_args.kwargs.copy() model_args.pop("input") assert model_args.pop("user") == result.conversation_id + assert model_args.pop("prompt_cache_key") == subentry.subentry_id assert model_args == snapshot From 43d30cdd0d93e466bda629e5b0330b24307bb970 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=85ke=20Strandberg?= Date: Mon, 7 Sep 2026 08:35:33 +0200 Subject: [PATCH 09/20] Bump pysenz to 1.1.2 (#181435) --- homeassistant/components/senz/manifest.json | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/senz/manifest.json b/homeassistant/components/senz/manifest.json index aca6bce3f946e3..4dd43f6c4b919a 100644 --- a/homeassistant/components/senz/manifest.json +++ b/homeassistant/components/senz/manifest.json @@ -8,5 +8,5 @@ "integration_type": "hub", "iot_class": "cloud_polling", "loggers": ["pysenz"], - "requirements": ["pysenz==1.0.2"] + "requirements": ["pysenz==1.1.2"] } diff --git a/requirements_all.txt b/requirements_all.txt index 3a1a0bed9c0176..f62cb7fd4a0936 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2597,7 +2597,7 @@ pyscorpiontrack==0.1.1 pysensibo==1.2.1 # homeassistant.components.senz -pysenz==1.0.2 +pysenz==1.1.2 # homeassistant.components.sesame pysesame2==1.0.2 From a09b33ab9e76e9d7ea5a47ce703207ed1590b6e3 Mon Sep 17 00:00:00 2001 From: Ronald van der Meer Date: Mon, 7 Sep 2026 08:37:14 +0200 Subject: [PATCH 10/20] Fix delayed Duco bypass target updates (#181367) --- homeassistant/components/duco/number.py | 15 ++++- tests/components/duco/conftest.py | 8 +-- tests/components/duco/test_number.py | 90 +++++++++++++++++++++---- 3 files changed, 94 insertions(+), 19 deletions(-) diff --git a/homeassistant/components/duco/number.py b/homeassistant/components/duco/number.py index dcb87784a45a79..6ba92ee78c8b70 100644 --- a/homeassistant/components/duco/number.py +++ b/homeassistant/components/duco/number.py @@ -1,5 +1,6 @@ """Number platform for the Duco integration.""" +from dataclasses import replace import logging from typing import override @@ -129,7 +130,7 @@ async def async_set_native_value(self, value: float) -> None: try: if self.unit_of_measurement != self.native_unit_of_measurement: value = target.normalize_value(value) - await self.coordinator.client.async_set_bypass_supply_temperature_target( + updated_target = await self.coordinator.client.async_set_bypass_supply_temperature_target( self._zone_id, value, target=target ) except ValueError as err: @@ -157,4 +158,14 @@ async def async_set_native_value(self, value: float) -> None: translation_key="failed_to_set_bypass_supply_temperature_target", ) from err - await self.coordinator.async_request_refresh() + # Do not let a completed write mask a concurrent coordinator refresh failure. + if self.coordinator.last_update_success: + self.coordinator.async_set_updated_data( + replace( + self.coordinator.data, + bypass_supply_temperature_targets={ + **self.coordinator.data.bypass_supply_temperature_targets, + self._zone_id: updated_target, + }, + ) + ) diff --git a/tests/components/duco/conftest.py b/tests/components/duco/conftest.py index 8dbf86d35fdf3f..d82b155ddc8790 100644 --- a/tests/components/duco/conftest.py +++ b/tests/components/duco/conftest.py @@ -282,11 +282,11 @@ def set_bypass_supply_temperature_target( temperature: float, *, target: BypassSupplyTemperatureTarget, - ) -> None: + ) -> BypassSupplyTemperatureTarget: target.validate_value(temperature) - mock_bypass_supply_temperature_targets[zone_id] = replace( - target, value=temperature - ) + updated_target = replace(target, zone_id=zone_id, value=temperature) + mock_bypass_supply_temperature_targets[zone_id] = updated_target + return updated_target with ( patch( diff --git a/tests/components/duco/test_number.py b/tests/components/duco/test_number.py index a5c2f9e273df61..8f59182f4659bd 100644 --- a/tests/components/duco/test_number.py +++ b/tests/components/duco/test_number.py @@ -1,7 +1,8 @@ """Tests for the Duco number platform.""" +import asyncio from dataclasses import replace -from unittest.mock import AsyncMock +from unittest.mock import AsyncMock, call from duco_connectivity import ( BypassSupplyTemperatureTarget, @@ -66,6 +67,61 @@ async def test_bypass_supply_temperature_target_numbers_support_all_exposed_zone mock_duco_client.async_get_bypass_supply_temperature_targets.assert_awaited_once_with() +async def test_successful_write_does_not_recover_failed_coordinator( + hass: HomeAssistant, + mock_bypass_supply_temperature_targets: dict[int, BypassSupplyTemperatureTarget], + mock_config_entry: MockConfigEntry, + mock_duco_client: AsyncMock, +) -> None: + """Test a successful write does not recover a failed coordinator.""" + await setup_platform_integration(hass, mock_config_entry, [Platform.NUMBER]) + write_started = asyncio.Event() + release_write = asyncio.Event() + target = mock_bypass_supply_temperature_targets[1] + + async def set_bypass_supply_temperature_target( + zone_id: int, + temperature: float, + *, + target: BypassSupplyTemperatureTarget, + ) -> BypassSupplyTemperatureTarget: + updated_target = replace(target, zone_id=zone_id, value=temperature) + mock_bypass_supply_temperature_targets[zone_id] = updated_target + write_started.set() + await release_write.wait() + return updated_target + + mock_duco_client.async_set_bypass_supply_temperature_target.side_effect = ( + set_bypass_supply_temperature_target + ) + write_task = asyncio.create_task( + hass.services.async_call( + NUMBER_DOMAIN, + SERVICE_SET_VALUE, + {ATTR_ENTITY_ID: _ZONE_1_ENTITY_ID, "value": 20.5}, + blocking=True, + ) + ) + await write_started.wait() + + mock_duco_client.async_get_nodes.side_effect = DucoError("Temporary update failure") + await mock_config_entry.runtime_data.async_refresh() + + state = hass.states.get(_ZONE_1_ENTITY_ID) + assert state is not None + assert state.state == STATE_UNAVAILABLE + + release_write.set() + await write_task + + mock_duco_client.async_set_bypass_supply_temperature_target.assert_awaited_once_with( + 1, 20.5, target=target + ) + state = hass.states.get(_ZONE_1_ENTITY_ID) + assert state is not None + assert state.state == STATE_UNAVAILABLE + + @pytest.mark.usefixtures("entity_registry_enabled_by_default", "init_integration") async def test_bypass_supply_temperature_target_number_entities_state( hass: HomeAssistant, @@ -98,22 +154,30 @@ async def test_set_bypass_supply_temperature_target( mock_bypass_supply_temperature_targets: dict[int, BypassSupplyTemperatureTarget], mock_duco_client: AsyncMock, ) -> None: - """Test setting a bypass target refreshes the number from the box.""" + """Test consecutive bypass target writes update directly from their responses.""" target = mock_bypass_supply_temperature_targets[1] - await hass.services.async_call( - NUMBER_DOMAIN, - SERVICE_SET_VALUE, - {ATTR_ENTITY_ID: _ZONE_1_ENTITY_ID, "value": 20.5}, - blocking=True, - ) + for value in (20.5, 21.0): + await hass.services.async_call( + NUMBER_DOMAIN, + SERVICE_SET_VALUE, + {ATTR_ENTITY_ID: _ZONE_1_ENTITY_ID, "value": value}, + blocking=True, + ) - mock_duco_client.async_set_bypass_supply_temperature_target.assert_awaited_once_with( - 1, 20.5, target=target + state = hass.states.get(_ZONE_1_ENTITY_ID) + assert state is not None + assert state.state == str(value) + + assert ( + mock_duco_client.async_set_bypass_supply_temperature_target.await_args_list + == [ + call(1, 20.5, target=target), + call(1, 21.0, target=replace(target, value=20.5)), + ] ) - state = hass.states.get(_ZONE_1_ENTITY_ID) - assert state is not None - assert state.state == "20.5" + mock_duco_client.async_get_bypass_supply_temperature_targets.assert_awaited_once_with() + assert mock_bypass_supply_temperature_targets[1] == replace(target, value=21.0) async def test_set_bypass_supply_temperature_target_honors_increment_metadata( From 00e0267a8c33eb1e164176d8ede7ef380aac9a92 Mon Sep 17 00:00:00 2001 From: Tomer <57483589+tomer-w@users.noreply.github.com> Date: Mon, 7 Sep 2026 09:40:09 +0300 Subject: [PATCH 11/20] victron_gx: Map Victron timestamp sensor device class (#181501) --- homeassistant/components/victron_gx/sensor.py | 1 + tests/components/victron_gx/test_sensor.py | 21 +++++++++++++++++++ 2 files changed, 22 insertions(+) diff --git a/homeassistant/components/victron_gx/sensor.py b/homeassistant/components/victron_gx/sensor.py index 2c3a80de051ddb..26052f1e010199 100644 --- a/homeassistant/components/victron_gx/sensor.py +++ b/homeassistant/components/victron_gx/sensor.py @@ -44,6 +44,7 @@ MetricType.DURATION: SensorDeviceClass.DURATION, MetricType.ENUM: SensorDeviceClass.ENUM, MetricType.IRRADIANCE: SensorDeviceClass.IRRADIANCE, + MetricType.TIMESTAMP: SensorDeviceClass.TIMESTAMP, } METRIC_NATURE_TO_STATE_CLASS: dict[MetricNature, SensorStateClass] = { diff --git a/tests/components/victron_gx/test_sensor.py b/tests/components/victron_gx/test_sensor.py index ac3994caaf5abb..a9321c874f1581 100644 --- a/tests/components/victron_gx/test_sensor.py +++ b/tests/components/victron_gx/test_sensor.py @@ -199,6 +199,27 @@ async def test_native_unit_of_measurement_with_device_class( assert state.attributes["unit_of_measurement"] == "A" +async def test_timestamp_device_class( + hass: HomeAssistant, + init_integration: tuple[VictronVenusHub, MockConfigEntry], +) -> None: + """Test timestamp metrics use the timestamp device class.""" + victron_hub, _mock_config_entry = init_integration + + await inject_message( + victron_hub, + f"N/{MOCK_INSTALLATION_ID}/system/0/DynamicEss/LastScheduledStart", + '{"value": 1756684800}', + ) + await finalize_injection(victron_hub) + await hass.async_block_till_done() + + state = hass.states.get("sensor.victron_venus_dynamic_ess_last_scheduled_start") + assert state is not None + assert state.state == "2025-09-01T00:00:00+00:00" + assert state.attributes["device_class"] == SensorDeviceClass.TIMESTAMP + + async def test_native_unit_of_measurement_special_unit( hass: HomeAssistant, init_integration: tuple[VictronVenusHub, MockConfigEntry], From 1c71fe99261c5702efd1e9cfccf44855f11d1df9 Mon Sep 17 00:00:00 2001 From: Josef Zweck Date: Mon, 7 Sep 2026 08:49:22 +0200 Subject: [PATCH 12/20] Drop redundant token error handling (#181079) Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .../components/aladdin_connect/__init__.py | 14 +------ homeassistant/components/august/__init__.py | 8 ++-- .../components/cloud/account_link.py | 40 ++----------------- .../components/electric_kiwi/__init__.py | 15 +------ homeassistant/components/google/__init__.py | 15 +------ .../google_assistant_sdk/__init__.py | 16 +------- .../google_assistant_sdk/strings.json | 3 -- .../components/google_photos/__init__.py | 16 +------- .../components/google_sheets/__init__.py | 20 +--------- .../components/google_tasks/__init__.py | 18 +-------- .../components/home_connect/__init__.py | 14 +------ .../husqvarna_automower/__init__.py | 14 +------ homeassistant/components/miele/__init__.py | 18 +-------- homeassistant/components/miele/strings.json | 6 --- homeassistant/components/neato/__init__.py | 15 +------ homeassistant/components/nest/__init__.py | 19 +-------- homeassistant/components/nest/strings.json | 6 --- homeassistant/components/netatmo/__init__.py | 15 +------ homeassistant/components/onedrive/__init__.py | 18 +-------- .../onedrive_for_business/__init__.py | 18 +-------- .../components/smartthings/__init__.py | 14 +------ .../components/tesla_fleet/__init__.py | 7 +--- homeassistant/components/tibber/__init__.py | 17 +------- homeassistant/components/twitch/__init__.py | 16 +------- homeassistant/components/xbox/api.py | 21 +--------- homeassistant/components/yale/__init__.py | 8 ++-- homeassistant/components/yoto/__init__.py | 19 +-------- homeassistant/components/youtube/__init__.py | 17 +------- tests/components/aladdin_connect/test_init.py | 3 +- tests/components/cloud/test_account_link.py | 4 +- tests/components/onedrive/test_init.py | 12 +++--- .../onedrive_for_business/test_init.py | 12 +++--- tests/components/twitch/test_init.py | 4 +- tests/components/yoto/test_init.py | 3 +- tests/components/youtube/test_init.py | 4 +- 35 files changed, 66 insertions(+), 403 deletions(-) diff --git a/homeassistant/components/aladdin_connect/__init__.py b/homeassistant/components/aladdin_connect/__init__.py index 119227505074a5..8ff6926dfb8bc5 100644 --- a/homeassistant/components/aladdin_connect/__init__.py +++ b/homeassistant/components/aladdin_connect/__init__.py @@ -1,16 +1,9 @@ """The Aladdin Connect Genie integration.""" -import aiohttp from genie_partner_sdk.client import AladdinConnectClient from homeassistant.const import Platform from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ( - ConfigEntryAuthFailed, - ConfigEntryNotReady, - OAuth2TokenRequestError, - OAuth2TokenRequestReauthError, -) from homeassistant.helpers import ( aiohttp_client, config_entry_oauth2_flow, @@ -36,12 +29,7 @@ async def async_setup_entry( session = config_entry_oauth2_flow.OAuth2Session(hass, entry, implementation) - try: - await session.async_ensure_token_valid() - except OAuth2TokenRequestReauthError as err: - raise ConfigEntryAuthFailed(err) from err - except (OAuth2TokenRequestError, aiohttp.ClientError) as err: - raise ConfigEntryNotReady from err + await session.async_ensure_token_valid() client = AladdinConnectClient( api.AsyncConfigEntryAuth(aiohttp_client.async_get_clientsession(hass), session) diff --git a/homeassistant/components/august/__init__.py b/homeassistant/components/august/__init__.py index 74328139c30a99..66ca71a61f54b0 100644 --- a/homeassistant/components/august/__init__.py +++ b/homeassistant/components/august/__init__.py @@ -14,8 +14,7 @@ from homeassistant.exceptions import ( ConfigEntryAuthFailed, ConfigEntryNotReady, - OAuth2TokenRequestError, - OAuth2TokenRequestReauthError, + OAuth2TokenRequestBaseError, ) from homeassistant.helpers import device_registry as dr, issue_registry as ir from homeassistant.helpers.config_entry_oauth2_flow import ( @@ -44,15 +43,14 @@ async def async_setup_entry(hass: HomeAssistant, entry: AugustConfigEntry) -> bo august_gateway = AugustGateway(Path(hass.config.config_dir), session, oauth_session) try: await async_setup_august(hass, entry, august_gateway) - except OAuth2TokenRequestReauthError as err: - raise ConfigEntryAuthFailed from err + except OAuth2TokenRequestBaseError: + raise except (RequireValidation, InvalidAuth) as err: raise ConfigEntryAuthFailed from err except TimeoutError as err: raise ConfigEntryNotReady("Timed out connecting to august api") from err except ( AugustApiAIOHTTPError, - OAuth2TokenRequestError, ClientError, CannotConnect, ) as err: diff --git a/homeassistant/components/cloud/account_link.py b/homeassistant/components/cloud/account_link.py index 13a48ab13ec673..c497dfd04afd8f 100644 --- a/homeassistant/components/cloud/account_link.py +++ b/homeassistant/components/cloud/account_link.py @@ -1,7 +1,6 @@ """Account linking via the cloud.""" from datetime import datetime -from http import HTTPStatus import logging from typing import Any, override @@ -11,11 +10,6 @@ from homeassistant.const import __version__ as HA_VERSION from homeassistant.core import HomeAssistant, callback -from homeassistant.exceptions import ( - OAuth2TokenRequestError, - OAuth2TokenRequestReauthError, - OAuth2TokenRequestTransientError, -) from homeassistant.helpers import config_entry_oauth2_flow, event from .const import DATA_CLOUD, DOMAIN @@ -163,35 +157,7 @@ async def async_resolve_external_data(self, external_data: Any) -> dict: @override async def _async_refresh_token(self, token: dict) -> dict: """Refresh a token.""" - try: - new_token = await account_link.async_fetch_access_token( - self.hass.data[DATA_CLOUD], self.service, token["refresh_token"] - ) - except aiohttp.ClientResponseError as err: - if err.status == HTTPStatus.TOO_MANY_REQUESTS or 500 <= err.status <= 599: - raise OAuth2TokenRequestTransientError( - request_info=err.request_info, - history=err.history, - status=err.status, - message=err.message, - headers=err.headers, - domain=self.service, - ) from err - if 400 <= err.status <= 499: - raise OAuth2TokenRequestReauthError( - request_info=err.request_info, - history=err.history, - status=err.status, - message=err.message, - headers=err.headers, - domain=self.service, - ) from err - raise OAuth2TokenRequestError( - request_info=err.request_info, - history=err.history, - status=err.status, - message=err.message, - headers=err.headers, - domain=self.service, - ) from err + new_token = await account_link.async_fetch_access_token( + self.hass.data[DATA_CLOUD], self.service, token["refresh_token"] + ) return {**token, **new_token} diff --git a/homeassistant/components/electric_kiwi/__init__.py b/homeassistant/components/electric_kiwi/__init__.py index 2f1e0ab06f75bd..ff15392392c61b 100644 --- a/homeassistant/components/electric_kiwi/__init__.py +++ b/homeassistant/components/electric_kiwi/__init__.py @@ -1,17 +1,11 @@ """The Electric Kiwi integration.""" -import aiohttp from electrickiwi_api import ElectricKiwiApi from electrickiwi_api.exceptions import ApiException, AuthException from homeassistant.const import Platform from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ( - ConfigEntryAuthFailed, - ConfigEntryNotReady, - OAuth2TokenRequestError, - OAuth2TokenRequestReauthError, -) +from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.helpers import ( aiohttp_client, config_entry_oauth2_flow, @@ -41,12 +35,7 @@ async def async_setup_entry( session = config_entry_oauth2_flow.OAuth2Session(hass, entry, implementation) - try: - await session.async_ensure_token_valid() - except OAuth2TokenRequestReauthError as err: - raise ConfigEntryAuthFailed(err) from err - except (OAuth2TokenRequestError, aiohttp.ClientError) as err: - raise ConfigEntryNotReady from err + await session.async_ensure_token_valid() ek_api = ElectricKiwiApi( api.ConfigEntryElectricKiwiAuth( diff --git a/homeassistant/components/google/__init__.py b/homeassistant/components/google/__init__.py index b08f0520c56d03..c0e32f4f3c38d5 100644 --- a/homeassistant/components/google/__init__.py +++ b/homeassistant/components/google/__init__.py @@ -6,7 +6,6 @@ import time from typing import Any -import aiohttp from gcal_sync.api import GoogleCalendarService from gcal_sync.exceptions import ApiException, AuthException import voluptuous as vol @@ -20,12 +19,7 @@ Platform, ) from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ( - ConfigEntryAuthFailed, - ConfigEntryNotReady, - OAuth2TokenRequestError, - OAuth2TokenRequestReauthError, -) +from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.helpers import config_entry_oauth2_flow, config_validation as cv from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.entity import generate_entity_id @@ -105,12 +99,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: GoogleConfigEntry) -> bo if session.token["expires_at"] >= now + timedelta(days=365).total_seconds(): session.token["expires_in"] = 0 session.token["expires_at"] = now - try: - await session.async_ensure_token_valid() - except OAuth2TokenRequestReauthError as err: - raise ConfigEntryAuthFailed from err - except (OAuth2TokenRequestError, aiohttp.ClientError) as err: - raise ConfigEntryNotReady from err + await session.async_ensure_token_valid() if not async_entry_has_scopes(entry): raise ConfigEntryAuthFailed( diff --git a/homeassistant/components/google_assistant_sdk/__init__.py b/homeassistant/components/google_assistant_sdk/__init__.py index 31b609812338f6..0614ed5596553b 100644 --- a/homeassistant/components/google_assistant_sdk/__init__.py +++ b/homeassistant/components/google_assistant_sdk/__init__.py @@ -3,19 +3,12 @@ import asyncio from typing import override -from aiohttp import ClientError from gassist_text import TextAssistantAsync from google.oauth2.credentials import Credentials from homeassistant.components import conversation from homeassistant.const import CONF_ACCESS_TOKEN, CONF_NAME, Platform from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ( - ConfigEntryAuthFailed, - ConfigEntryNotReady, - OAuth2TokenRequestError, - OAuth2TokenRequestReauthError, -) from homeassistant.helpers import config_validation as cv, discovery, intent from homeassistant.helpers.config_entry_oauth2_flow import ( OAuth2Session, @@ -55,14 +48,7 @@ async def async_setup_entry( """Set up Google Assistant SDK from a config entry.""" implementation = await async_get_config_entry_implementation(hass, entry) session = OAuth2Session(hass, entry, implementation) - try: - await session.async_ensure_token_valid() - except OAuth2TokenRequestReauthError as err: - raise ConfigEntryAuthFailed( - translation_domain=DOMAIN, translation_key="reauth_required" - ) from err - except (OAuth2TokenRequestError, ClientError) as err: - raise ConfigEntryNotReady from err + await session.async_ensure_token_valid() mem_storage = InMemoryStorage(hass) hass.http.register_view(GoogleAssistantSDKAudioView(mem_storage)) diff --git a/homeassistant/components/google_assistant_sdk/strings.json b/homeassistant/components/google_assistant_sdk/strings.json index c22a6d3525003e..4abb3dd1009238 100644 --- a/homeassistant/components/google_assistant_sdk/strings.json +++ b/homeassistant/components/google_assistant_sdk/strings.json @@ -37,9 +37,6 @@ }, "grpc_error": { "message": "Failed to communicate with Google Assistant" - }, - "reauth_required": { - "message": "Credentials are invalid, re-authentication required" } }, "options": { diff --git a/homeassistant/components/google_photos/__init__.py b/homeassistant/components/google_photos/__init__.py index dd2095c694e2a9..98fa706d7f5c72 100644 --- a/homeassistant/components/google_photos/__init__.py +++ b/homeassistant/components/google_photos/__init__.py @@ -1,15 +1,8 @@ """The Google Photos integration.""" -from aiohttp import ClientError from google_photos_library_api.api import GooglePhotosLibraryApi from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ( - ConfigEntryAuthFailed, - ConfigEntryNotReady, - OAuth2TokenRequestError, - OAuth2TokenRequestReauthError, -) from homeassistant.helpers import config_entry_oauth2_flow, config_validation as cv from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.typing import ConfigType @@ -45,14 +38,7 @@ async def async_setup_entry( web_session = async_get_clientsession(hass) oauth_session = config_entry_oauth2_flow.OAuth2Session(hass, entry, implementation) auth = api.AsyncConfigEntryAuth(web_session, oauth_session) - try: - await auth.async_get_access_token() - except OAuth2TokenRequestReauthError as err: - raise ConfigEntryAuthFailed( - "OAuth session is not valid, reauth required" - ) from err - except (OAuth2TokenRequestError, ClientError) as err: - raise ConfigEntryNotReady from err + await auth.async_get_access_token() coordinator = GooglePhotosUpdateCoordinator( hass, entry, GooglePhotosLibraryApi(auth) ) diff --git a/homeassistant/components/google_sheets/__init__.py b/homeassistant/components/google_sheets/__init__.py index 869a329afd00a3..5eda558fd422a0 100644 --- a/homeassistant/components/google_sheets/__init__.py +++ b/homeassistant/components/google_sheets/__init__.py @@ -1,16 +1,9 @@ """Support for Google Sheets.""" -import aiohttp - from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_TOKEN from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ( - ConfigEntryAuthFailed, - ConfigEntryNotReady, - OAuth2TokenRequestError, - OAuth2TokenRequestReauthError, -) +from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.helpers import config_validation as cv from homeassistant.helpers.config_entry_oauth2_flow import ( OAuth2Session, @@ -40,16 +33,7 @@ async def async_setup_entry( """Set up Google Sheets from a config entry.""" implementation = await async_get_config_entry_implementation(hass, entry) session = OAuth2Session(hass, entry, implementation) - try: - await session.async_ensure_token_valid() - except OAuth2TokenRequestReauthError as err: - raise ConfigEntryAuthFailed( - "OAuth session is not valid, reauth required" - ) from err - except OAuth2TokenRequestError as err: - raise ConfigEntryNotReady from err - except aiohttp.ClientError as err: - raise ConfigEntryNotReady from err + await session.async_ensure_token_valid() if not async_entry_has_scopes(hass, entry): raise ConfigEntryAuthFailed("Required scopes are not present, reauth required") diff --git a/homeassistant/components/google_tasks/__init__.py b/homeassistant/components/google_tasks/__init__.py index cb0f1038c33b02..4fd72fd2ca08ef 100644 --- a/homeassistant/components/google_tasks/__init__.py +++ b/homeassistant/components/google_tasks/__init__.py @@ -2,16 +2,9 @@ import asyncio -from aiohttp import ClientError - from homeassistant.const import Platform from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ( - ConfigEntryAuthFailed, - ConfigEntryNotReady, - OAuth2TokenRequestError, - OAuth2TokenRequestReauthError, -) +from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers import config_entry_oauth2_flow from . import api @@ -35,14 +28,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: GoogleTasksConfigEntry) ) session = config_entry_oauth2_flow.OAuth2Session(hass, entry, implementation) auth = api.AsyncConfigEntryAuth(hass, session) - try: - await auth.async_get_access_token() - except OAuth2TokenRequestReauthError as err: - raise ConfigEntryAuthFailed( - "OAuth session is not valid, reauth required" - ) from err - except (OAuth2TokenRequestError, ClientError) as err: - raise ConfigEntryNotReady from err + await auth.async_get_access_token() try: task_lists = await auth.list_task_lists() diff --git a/homeassistant/components/home_connect/__init__.py b/homeassistant/components/home_connect/__init__.py index 414ceac9aedf01..673a7068cce53c 100644 --- a/homeassistant/components/home_connect/__init__.py +++ b/homeassistant/components/home_connect/__init__.py @@ -5,17 +5,10 @@ from aiohomeconnect.client import Client as HomeConnectClient from aiohomeconnect.model import EventKey -import aiohttp import jwt from homeassistant.const import Platform from homeassistant.core import HomeAssistant, callback -from homeassistant.exceptions import ( - ConfigEntryAuthFailed, - ConfigEntryNotReady, - OAuth2TokenRequestError, - OAuth2TokenRequestReauthError, -) from homeassistant.helpers import ( config_validation as cv, device_registry as dr, @@ -63,12 +56,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: HomeConnectConfigEntry) session = OAuth2Session(hass, entry, implementation) config_entry_auth = AsyncConfigEntryAuth(hass, session) - try: - await config_entry_auth.async_get_access_token() - except OAuth2TokenRequestReauthError as err: - raise ConfigEntryAuthFailed from err - except (OAuth2TokenRequestError, aiohttp.ClientError) as err: - raise ConfigEntryNotReady from err + await config_entry_auth.async_get_access_token() home_connect_client = HomeConnectClient(config_entry_auth) diff --git a/homeassistant/components/husqvarna_automower/__init__.py b/homeassistant/components/husqvarna_automower/__init__.py index 6994867f275c20..667090a732a57a 100644 --- a/homeassistant/components/husqvarna_automower/__init__.py +++ b/homeassistant/components/husqvarna_automower/__init__.py @@ -4,12 +4,7 @@ from homeassistant.const import Platform from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ( - ConfigEntryAuthFailed, - ConfigEntryNotReady, - OAuth2TokenRequestError, - OAuth2TokenRequestReauthError, -) +from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.helpers import ( aiohttp_client, config_entry_oauth2_flow, @@ -61,12 +56,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: AutomowerConfigEntry) -> api_api, await dt_util.async_get_time_zone(time_zone_str), ) - try: - await api_api.async_get_access_token() - except OAuth2TokenRequestReauthError as err: - raise ConfigEntryAuthFailed from err - except OAuth2TokenRequestError as err: - raise ConfigEntryNotReady from err + await api_api.async_get_access_token() if "amc:api" not in entry.data["token"]["scope"]: # We raise ConfigEntryAuthFailed here because the websocket can't be used diff --git a/homeassistant/components/miele/__init__.py b/homeassistant/components/miele/__init__.py index 7df2ddc06ceb07..3a24745687a5c1 100644 --- a/homeassistant/components/miele/__init__.py +++ b/homeassistant/components/miele/__init__.py @@ -1,16 +1,9 @@ """The Miele integration.""" -from aiohttp import ClientError from pymiele import MieleAPI from homeassistant.const import Platform from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ( - ConfigEntryAuthFailed, - ConfigEntryNotReady, - OAuth2TokenRequestError, - OAuth2TokenRequestReauthError, -) from homeassistant.helpers import config_validation as cv, device_registry as dr from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.config_entry_oauth2_flow import ( @@ -57,16 +50,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: MieleConfigEntry) -> boo session = OAuth2Session(hass, entry, implementation) auth = AsyncConfigEntryAuth(async_get_clientsession(hass), session) - try: - await auth.async_get_access_token() - except OAuth2TokenRequestReauthError as err: - raise ConfigEntryAuthFailed( - translation_domain=DOMAIN, translation_key="config_entry_auth_failed" - ) from err - except (OAuth2TokenRequestError, ClientError) as err: - raise ConfigEntryNotReady( - translation_domain=DOMAIN, translation_key="config_entry_not_ready" - ) from err + await auth.async_get_access_token() # Setup MieleAPI and coordinator for data fetch _api = MieleAPI(auth) diff --git a/homeassistant/components/miele/strings.json b/homeassistant/components/miele/strings.json index 5070eb3c46534b..4b7f81a54da0e5 100644 --- a/homeassistant/components/miele/strings.json +++ b/homeassistant/components/miele/strings.json @@ -1146,12 +1146,6 @@ } }, "exceptions": { - "config_entry_auth_failed": { - "message": "Authentication failed. Please log in again." - }, - "config_entry_not_ready": { - "message": "Error while loading the integration." - }, "get_programs_error": { "message": "'Get programs' action failed: {status} / {message}" }, diff --git a/homeassistant/components/neato/__init__.py b/homeassistant/components/neato/__init__.py index 77a5759be2f79f..2e1b8b4969b30c 100644 --- a/homeassistant/components/neato/__init__.py +++ b/homeassistant/components/neato/__init__.py @@ -2,19 +2,13 @@ import logging -from aiohttp import ClientError from pybotvac import Account from pybotvac.exceptions import NeatoException from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_TOKEN, Platform from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ( - ConfigEntryAuthFailed, - ConfigEntryNotReady, - OAuth2TokenRequestError, - OAuth2TokenRequestReauthError, -) +from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.helpers import config_validation as cv from homeassistant.helpers.config_entry_oauth2_flow import ( OAuth2Session, @@ -55,12 +49,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: NeatoConfigEntry) -> boo implementation = await async_get_config_entry_implementation(hass, entry) session = OAuth2Session(hass, entry, implementation) - try: - await session.async_ensure_token_valid() - except OAuth2TokenRequestReauthError as ex: - raise ConfigEntryAuthFailed from ex - except (OAuth2TokenRequestError, ClientError) as ex: - raise ConfigEntryNotReady from ex + await session.async_ensure_token_valid() neato_session = api.ConfigEntryAuth(hass, entry, implementation) hub = NeatoHub(hass, Account(neato_session)) diff --git a/homeassistant/components/nest/__init__.py b/homeassistant/components/nest/__init__.py index 2a71da83ac0847..112e2f3db72c22 100644 --- a/homeassistant/components/nest/__init__.py +++ b/homeassistant/components/nest/__init__.py @@ -6,7 +6,7 @@ import logging from typing import override -from aiohttp import ClientError, web +from aiohttp import web from google_nest_sdm.camera_traits import CameraClipPreviewTrait from google_nest_sdm.device import Device from google_nest_sdm.device_manager import DeviceManager @@ -42,8 +42,6 @@ ConfigEntryAuthFailed, ConfigEntryNotReady, HomeAssistantError, - OAuth2TokenRequestError, - OAuth2TokenRequestReauthError, Unauthorized, ) from homeassistant.helpers import ( @@ -251,20 +249,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: NestConfigEntry) -> bool ) auth = await api.new_auth(hass, entry) - try: - await auth.async_get_access_token() - except OAuth2TokenRequestReauthError as err: - raise ConfigEntryAuthFailed( - translation_domain=DOMAIN, translation_key="reauth_required" - ) from err - except OAuth2TokenRequestError as err: - raise ConfigEntryNotReady( - translation_domain=DOMAIN, translation_key="auth_server_error" - ) from err - except ClientError as err: - raise ConfigEntryNotReady( - translation_domain=DOMAIN, translation_key="auth_client_error" - ) from err + await auth.async_get_access_token() subscriber = await api.new_subscriber(hass, entry, auth) if not subscriber: diff --git a/homeassistant/components/nest/strings.json b/homeassistant/components/nest/strings.json index d3c4b3f260eaaa..edbb759a81cf68 100644 --- a/homeassistant/components/nest/strings.json +++ b/homeassistant/components/nest/strings.json @@ -129,12 +129,6 @@ } }, "exceptions": { - "auth_client_error": { - "message": "Client error during authentication, please check your network connection." - }, - "auth_server_error": { - "message": "Error response from authentication server, please see logs for details." - }, "device_api_error": { "message": "Error communicating with the Device Access API, please see logs for details." }, diff --git a/homeassistant/components/netatmo/__init__.py b/homeassistant/components/netatmo/__init__.py index 603935a618d272..371175ea64c094 100644 --- a/homeassistant/components/netatmo/__init__.py +++ b/homeassistant/components/netatmo/__init__.py @@ -3,19 +3,13 @@ import logging from typing import Any -from aiohttp import ClientError import pyatmo from homeassistant.components import cloud from homeassistant.components.webhook import async_unregister as webhook_unregister from homeassistant.const import CONF_WEBHOOK_ID from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ( - ConfigEntryAuthFailed, - ConfigEntryNotReady, - OAuth2TokenRequestError, - OAuth2TokenRequestReauthError, -) +from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.helpers import ( aiohttp_client, config_validation as cv, @@ -60,12 +54,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: NetatmoConfigEntry) -> b hass.config_entries.async_update_entry(entry, unique_id=DOMAIN) session = OAuth2Session(hass, entry, implementation) - try: - await session.async_ensure_token_valid() - except OAuth2TokenRequestReauthError as ex: - raise ConfigEntryAuthFailed("Token not valid, trigger renewal") from ex - except (OAuth2TokenRequestError, ClientError) as ex: - raise ConfigEntryNotReady from ex + await session.async_ensure_token_valid() required_scopes = api.get_api_scopes(entry.data["auth_implementation"]) if not (set(session.token["scope"]) & set(required_scopes)): diff --git a/homeassistant/components/onedrive/__init__.py b/homeassistant/components/onedrive/__init__.py index 3428df1512cf46..1e8645a33792f3 100644 --- a/homeassistant/components/onedrive/__init__.py +++ b/homeassistant/components/onedrive/__init__.py @@ -15,12 +15,7 @@ from homeassistant.const import CONF_ACCESS_TOKEN, Platform from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ( - ConfigEntryAuthFailed, - ConfigEntryNotReady, - OAuth2TokenRequestError, - OAuth2TokenRequestReauthError, -) +from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.helpers import config_validation as cv from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.config_entry_oauth2_flow import ( @@ -174,16 +169,7 @@ async def _get_onedrive_client( session = OAuth2Session(hass, entry, implementation) # Refresh up front, so a failure surfaces here instead of from inside the client - try: - await session.async_ensure_token_valid() - except OAuth2TokenRequestReauthError as err: - raise ConfigEntryAuthFailed( - translation_domain=DOMAIN, translation_key="authentication_failed" - ) from err - except OAuth2TokenRequestError as err: - raise ConfigEntryNotReady( - translation_domain=DOMAIN, translation_key="connection_error" - ) from err + await session.async_ensure_token_valid() async def get_access_token() -> str: await session.async_ensure_token_valid() diff --git a/homeassistant/components/onedrive_for_business/__init__.py b/homeassistant/components/onedrive_for_business/__init__.py index 7ba0281f070570..678947a0713d03 100644 --- a/homeassistant/components/onedrive_for_business/__init__.py +++ b/homeassistant/components/onedrive_for_business/__init__.py @@ -13,12 +13,7 @@ from homeassistant.const import CONF_ACCESS_TOKEN, Platform from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ( - ConfigEntryAuthFailed, - ConfigEntryNotReady, - OAuth2TokenRequestError, - OAuth2TokenRequestReauthError, -) +from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.config_entry_oauth2_flow import ( OAuth2Session, @@ -99,16 +94,7 @@ async def _get_onedrive_client( session = OAuth2Session(hass, entry, implementation) # Refresh up front, so a failure surfaces here instead of from inside the client - try: - await session.async_ensure_token_valid() - except OAuth2TokenRequestReauthError as err: - raise ConfigEntryAuthFailed( - translation_domain=DOMAIN, translation_key="authentication_failed" - ) from err - except OAuth2TokenRequestError as err: - raise ConfigEntryNotReady( - translation_domain=DOMAIN, translation_key="connection_error" - ) from err + await session.async_ensure_token_valid() async def get_access_token() -> str: await session.async_ensure_token_valid() diff --git a/homeassistant/components/smartthings/__init__.py b/homeassistant/components/smartthings/__init__.py index df3d8b0986a5b2..aec9070d43cd72 100644 --- a/homeassistant/components/smartthings/__init__.py +++ b/homeassistant/components/smartthings/__init__.py @@ -49,12 +49,7 @@ Platform, ) from homeassistant.core import Event, HomeAssistant -from homeassistant.exceptions import ( - ConfigEntryAuthFailed, - ConfigEntryNotReady, - OAuth2TokenRequestError, - OAuth2TokenRequestReauthError, -) +from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.config_entry_oauth2_flow import ( @@ -136,12 +131,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: SmartThingsConfigEntry) implementation = await async_get_config_entry_implementation(hass, entry) session = OAuth2Session(hass, entry, implementation) - try: - await session.async_ensure_token_valid() - except OAuth2TokenRequestReauthError as err: - raise ConfigEntryAuthFailed from err - except OAuth2TokenRequestError as err: - raise ConfigEntryNotReady from err + await session.async_ensure_token_valid() client = SmartThings(session=async_get_clientsession(hass)) diff --git a/homeassistant/components/tesla_fleet/__init__.py b/homeassistant/components/tesla_fleet/__init__.py index be1b0ef2b39c3f..b9ddcc439f3d80 100644 --- a/homeassistant/components/tesla_fleet/__init__.py +++ b/homeassistant/components/tesla_fleet/__init__.py @@ -110,12 +110,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: TeslaFleetConfigEntry) - implementation = await async_get_config_entry_implementation(hass, entry) oauth_session = OAuth2Session(hass, entry, implementation) - try: - await oauth_session.async_ensure_token_valid() - except OAuth2TokenRequestReauthError as err: - raise ConfigEntryAuthFailed from err - except OAuth2TokenRequestError as err: - raise ConfigEntryNotReady from err + await oauth_session.async_ensure_token_valid() access_token = oauth_session.token[CONF_ACCESS_TOKEN] session = async_get_clientsession(hass) diff --git a/homeassistant/components/tibber/__init__.py b/homeassistant/components/tibber/__init__.py index 750d20ceac9bca..1fddcde7e4c330 100644 --- a/homeassistant/components/tibber/__init__.py +++ b/homeassistant/components/tibber/__init__.py @@ -6,17 +6,11 @@ from typing import Final import aiohttp -from aiohttp.client_exceptions import ClientError import tibber from homeassistant.const import CONF_ACCESS_TOKEN, EVENT_HOMEASSISTANT_STOP, Platform from homeassistant.core import Event, HomeAssistant -from homeassistant.exceptions import ( - ConfigEntryAuthFailed, - ConfigEntryNotReady, - OAuth2TokenRequestError, - OAuth2TokenRequestReauthError, -) +from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.helpers import config_validation as cv from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.config_entry_oauth2_flow import ( @@ -116,14 +110,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: TibberConfigEntry) -> bo implementation = await async_get_config_entry_implementation(hass, entry) session = OAuth2Session(hass, entry, implementation) - try: - await session.async_ensure_token_valid() - except OAuth2TokenRequestReauthError as err: - raise ConfigEntryAuthFailed( - "OAuth session is not valid, reauthentication required" - ) from err - except (OAuth2TokenRequestError, ClientError) as err: - raise ConfigEntryNotReady from err + await session.async_ensure_token_valid() entry.runtime_data = TibberRuntimeData( session=session, diff --git a/homeassistant/components/twitch/__init__.py b/homeassistant/components/twitch/__init__.py index aeb15e97916245..0e8ec10c6532fc 100644 --- a/homeassistant/components/twitch/__init__.py +++ b/homeassistant/components/twitch/__init__.py @@ -2,17 +2,10 @@ from typing import cast -from aiohttp.client_exceptions import ClientError from twitchAPI.twitch import Twitch from homeassistant.const import CONF_ACCESS_TOKEN, CONF_TOKEN from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ( - ConfigEntryAuthFailed, - ConfigEntryNotReady, - OAuth2TokenRequestError, - OAuth2TokenRequestReauthError, -) from homeassistant.helpers.config_entry_oauth2_flow import ( LocalOAuth2Implementation, OAuth2Session, @@ -30,14 +23,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: TwitchConfigEntry) -> bo await async_get_config_entry_implementation(hass, entry), ) session = OAuth2Session(hass, entry, implementation) - try: - await session.async_ensure_token_valid() - except OAuth2TokenRequestReauthError as err: - raise ConfigEntryAuthFailed( - "OAuth session is not valid, reauth required" - ) from err - except (OAuth2TokenRequestError, ClientError) as err: - raise ConfigEntryNotReady from err + await session.async_ensure_token_valid() access_token = entry.data[CONF_TOKEN][CONF_ACCESS_TOKEN] client = Twitch( diff --git a/homeassistant/components/xbox/api.py b/homeassistant/components/xbox/api.py index 7c1ab9ffddd716..308cc3219d3946 100644 --- a/homeassistant/components/xbox/api.py +++ b/homeassistant/components/xbox/api.py @@ -2,18 +2,12 @@ from typing import override -from aiohttp import ClientError from httpx import AsyncClient, HTTPStatusError, RequestError from pythonxbox.authentication.manager import AuthenticationManager from pythonxbox.authentication.models import OAuth2TokenResponse from pythonxbox.common.exceptions import AuthenticationException -from homeassistant.exceptions import ( - ConfigEntryAuthFailed, - ConfigEntryNotReady, - OAuth2TokenRequestReauthError, - OAuth2TokenRequestTransientError, -) +from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.helpers.config_entry_oauth2_flow import OAuth2Session from homeassistant.util.dt import utc_from_timestamp @@ -35,18 +29,7 @@ async def refresh_tokens(self) -> None: """Return a valid access token.""" if not self._oauth_session.valid_token: - try: - await self._oauth_session.async_ensure_token_valid() - except OAuth2TokenRequestReauthError as e: - raise ConfigEntryAuthFailed( - translation_domain=DOMAIN, - translation_key="auth_exception", - ) from e - except (OAuth2TokenRequestTransientError, ClientError) as e: - raise ConfigEntryNotReady( - translation_domain=DOMAIN, - translation_key="request_exception", - ) from e + await self._oauth_session.async_ensure_token_valid() self.oauth = self._get_oauth_token() # This will skip the OAuth refresh and only refresh User and XSTS tokens diff --git a/homeassistant/components/yale/__init__.py b/homeassistant/components/yale/__init__.py index e1a7bda85c4c0e..a83f7117a41b3f 100644 --- a/homeassistant/components/yale/__init__.py +++ b/homeassistant/components/yale/__init__.py @@ -16,8 +16,7 @@ from homeassistant.exceptions import ( ConfigEntryAuthFailed, ConfigEntryNotReady, - OAuth2TokenRequestError, - OAuth2TokenRequestReauthError, + OAuth2TokenRequestBaseError, ) from homeassistant.helpers import device_registry as dr from homeassistant.helpers.config_entry_oauth2_flow import ( @@ -41,15 +40,14 @@ async def async_setup_entry(hass: HomeAssistant, entry: YaleConfigEntry) -> bool yale_gateway = YaleGateway(Path(hass.config.config_dir), session, oauth_session) try: await async_setup_yale(hass, entry, yale_gateway) - except OAuth2TokenRequestReauthError as err: - raise ConfigEntryAuthFailed from err + except OAuth2TokenRequestBaseError: + raise except (RequireValidation, InvalidAuth) as err: raise ConfigEntryAuthFailed from err except TimeoutError as err: raise ConfigEntryNotReady("Timed out connecting to yale api") from err except ( YaleApiError, - OAuth2TokenRequestError, ClientError, CannotConnect, ) as err: diff --git a/homeassistant/components/yoto/__init__.py b/homeassistant/components/yoto/__init__.py index e20d0cbad886f9..d5f3c0652685fa 100644 --- a/homeassistant/components/yoto/__init__.py +++ b/homeassistant/components/yoto/__init__.py @@ -1,21 +1,12 @@ """The Yoto integration.""" -import aiohttp - from homeassistant.const import Platform from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ( - ConfigEntryAuthFailed, - ConfigEntryNotReady, - OAuth2TokenRequestError, - OAuth2TokenRequestReauthError, -) from homeassistant.helpers.config_entry_oauth2_flow import ( OAuth2Session, async_get_config_entry_implementation, ) -from .const import DOMAIN from .coordinator import YotoConfigEntry, YotoDataUpdateCoordinator PLATFORMS: list[Platform] = [ @@ -34,15 +25,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: YotoConfigEntry) -> bool implementation = await async_get_config_entry_implementation(hass, entry) session = OAuth2Session(hass, entry, implementation) - try: - await session.async_ensure_token_valid() - except OAuth2TokenRequestReauthError as err: - raise ConfigEntryAuthFailed( - translation_domain=DOMAIN, - translation_key="authentication_failed", - ) from err - except (aiohttp.ClientError, OAuth2TokenRequestError) as err: - raise ConfigEntryNotReady from err + await session.async_ensure_token_valid() coordinator = YotoDataUpdateCoordinator(hass, entry, session) await coordinator.async_config_entry_first_refresh() diff --git a/homeassistant/components/youtube/__init__.py b/homeassistant/components/youtube/__init__.py index a4a494442a55fe..2c1beae358e5f0 100644 --- a/homeassistant/components/youtube/__init__.py +++ b/homeassistant/components/youtube/__init__.py @@ -1,15 +1,7 @@ """Support for YouTube.""" -from aiohttp.client_exceptions import ClientError - from homeassistant.const import Platform from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ( - ConfigEntryAuthFailed, - ConfigEntryNotReady, - OAuth2TokenRequestError, - OAuth2TokenRequestReauthError, -) from homeassistant.helpers import device_registry as dr from homeassistant.helpers.config_entry_oauth2_flow import ( OAuth2Session, @@ -27,14 +19,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: YouTubeConfigEntry) -> b implementation = await async_get_config_entry_implementation(hass, entry) session = OAuth2Session(hass, entry, implementation) auth = AsyncConfigEntryAuth(hass, session) - try: - await auth.check_and_refresh_token() - except OAuth2TokenRequestReauthError as err: - raise ConfigEntryAuthFailed( - "OAuth session is not valid, reauth required" - ) from err - except (OAuth2TokenRequestError, ClientError) as err: - raise ConfigEntryNotReady from err + await auth.check_and_refresh_token() coordinator = YouTubeDataUpdateCoordinator(hass, entry, auth) await coordinator.async_config_entry_first_refresh() diff --git a/tests/components/aladdin_connect/test_init.py b/tests/components/aladdin_connect/test_init.py index 0fb40a0acfaa30..176fbd7e78d049 100644 --- a/tests/components/aladdin_connect/test_init.py +++ b/tests/components/aladdin_connect/test_init.py @@ -12,6 +12,7 @@ from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant from homeassistant.exceptions import ( + OAuth2TokenRequestConnectionError, OAuth2TokenRequestError, OAuth2TokenRequestReauthError, ) @@ -106,7 +107,7 @@ async def test_setup_entry_token_connection_error( """Test setup entry retries when token validation has a connection error.""" with patch( "homeassistant.helpers.config_entry_oauth2_flow.OAuth2Session.async_ensure_token_valid", - side_effect=ClientConnectionError(), + side_effect=OAuth2TokenRequestConnectionError(domain=DOMAIN), ): await init_integration(hass, mock_config_entry) diff --git a/tests/components/cloud/test_account_link.py b/tests/components/cloud/test_account_link.py index 43223228aef01f..7cc4a85c312a87 100644 --- a/tests/components/cloud/test_account_link.py +++ b/tests/components/cloud/test_account_link.py @@ -288,7 +288,7 @@ async def test_refresh_token_error( status: int, expected_exception: type[OAuth2TokenRequestError], ) -> None: - """Test that _async_refresh_token wraps ClientResponseError.""" + """Test a failing token request reports the service, not the cloud domain.""" hass.data[DATA_CLOUD] = None impl = account_link.CloudOAuth2Implementation(hass, "test") @@ -299,7 +299,7 @@ async def test_refresh_token_error( ), pytest.raises(expected_exception) as exc_info, ): - await impl._async_refresh_token( + await impl.async_refresh_token( {"refresh_token": "mock-refresh", "access_token": "mock-access"} ) diff --git a/tests/components/onedrive/test_init.py b/tests/components/onedrive/test_init.py index f56690506800ce..b064af70109c0b 100644 --- a/tests/components/onedrive/test_init.py +++ b/tests/components/onedrive/test_init.py @@ -62,26 +62,26 @@ async def test_load_unload_config_entry( @pytest.mark.parametrize( - ("status", "state", "reason", "reauth_expected"), + ("status", "state", "translation_key", "reauth_expected"), [ pytest.param( HTTPStatus.BAD_REQUEST, ConfigEntryState.SETUP_ERROR, - "Authentication failed", + "oauth2_helper_reauth_required", True, id="reauth", ), pytest.param( HTTPStatus.TOO_MANY_REQUESTS, ConfigEntryState.SETUP_RETRY, - "Failed to connect to OneDrive", + "oauth2_helper_refresh_transient", False, id="transient", ), pytest.param( HTTPStatus.INTERNAL_SERVER_ERROR, ConfigEntryState.SETUP_RETRY, - "Failed to connect to OneDrive", + "oauth2_helper_refresh_transient", False, id="server_error", ), @@ -94,7 +94,7 @@ async def test_token_refresh_errors( aioclient_mock: AiohttpClientMocker, status: HTTPStatus, state: ConfigEntryState, - reason: str, + translation_key: str, reauth_expected: bool, ) -> None: """Test a failing token refresh during setup.""" @@ -105,7 +105,7 @@ async def test_token_refresh_errors( await hass.async_block_till_done() assert mock_config_entry.state is state - assert mock_config_entry.reason == reason + assert mock_config_entry.error_reason_translation_key == translation_key assert bool(hass.config_entries.flow.async_progress()) is reauth_expected diff --git a/tests/components/onedrive_for_business/test_init.py b/tests/components/onedrive_for_business/test_init.py index 31329c94d9d683..f88b079f324f6a 100644 --- a/tests/components/onedrive_for_business/test_init.py +++ b/tests/components/onedrive_for_business/test_init.py @@ -56,26 +56,26 @@ async def test_load_unload_config_entry( @pytest.mark.parametrize( - ("status", "state", "reason", "reauth_expected"), + ("status", "state", "translation_key", "reauth_expected"), [ pytest.param( HTTPStatus.BAD_REQUEST, ConfigEntryState.SETUP_ERROR, - "Authentication failed", + "oauth2_helper_reauth_required", True, id="reauth", ), pytest.param( HTTPStatus.TOO_MANY_REQUESTS, ConfigEntryState.SETUP_RETRY, - "Failed to connect to OneDrive", + "oauth2_helper_refresh_transient", False, id="transient", ), pytest.param( HTTPStatus.INTERNAL_SERVER_ERROR, ConfigEntryState.SETUP_RETRY, - "Failed to connect to OneDrive", + "oauth2_helper_refresh_transient", False, id="server_error", ), @@ -88,7 +88,7 @@ async def test_token_refresh_errors( aioclient_mock: AiohttpClientMocker, status: HTTPStatus, state: ConfigEntryState, - reason: str, + translation_key: str, reauth_expected: bool, ) -> None: """Test a failing token refresh during setup.""" @@ -103,7 +103,7 @@ async def test_token_refresh_errors( await hass.async_block_till_done() assert mock_config_entry.state is state - assert mock_config_entry.reason == reason + assert mock_config_entry.error_reason_translation_key == translation_key assert bool(hass.config_entries.flow.async_progress()) is reauth_expected diff --git a/tests/components/twitch/test_init.py b/tests/components/twitch/test_init.py index 328171144d43ad..554b0b84475e92 100644 --- a/tests/components/twitch/test_init.py +++ b/tests/components/twitch/test_init.py @@ -4,12 +4,12 @@ import time from unittest.mock import AsyncMock, patch -from aiohttp.client_exceptions import ClientError import pytest from homeassistant.components.twitch.const import DOMAIN, OAUTH2_TOKEN from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant +from homeassistant.exceptions import OAuth2TokenRequestConnectionError from homeassistant.helpers.config_entry_oauth2_flow import ( ImplementationUnavailableError, ) @@ -113,7 +113,7 @@ async def test_expired_token_refresh_client_error( with patch( "homeassistant.components.twitch.OAuth2Session.async_ensure_token_valid", - side_effect=ClientError, + side_effect=OAuth2TokenRequestConnectionError(domain=DOMAIN), ): config_entry.add_to_hass(hass) diff --git a/tests/components/yoto/test_init.py b/tests/components/yoto/test_init.py index 3f5ca9ba284e89..5f4f31eb8297fb 100644 --- a/tests/components/yoto/test_init.py +++ b/tests/components/yoto/test_init.py @@ -15,6 +15,7 @@ from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntryState from homeassistant.core import HomeAssistant from homeassistant.exceptions import ( + OAuth2TokenRequestConnectionError, OAuth2TokenRequestError, OAuth2TokenRequestReauthError, ) @@ -151,7 +152,7 @@ async def test_setup_retries_when_implementation_missing( @pytest.mark.parametrize( "side_effect", [ - aiohttp.ClientError("boom"), + OAuth2TokenRequestConnectionError(domain=DOMAIN), OAuth2TokenRequestError(request_info=Mock(), domain=DOMAIN), ], ) diff --git a/tests/components/youtube/test_init.py b/tests/components/youtube/test_init.py index 58c13b634d3e9e..8930e7d82bf87e 100644 --- a/tests/components/youtube/test_init.py +++ b/tests/components/youtube/test_init.py @@ -4,12 +4,12 @@ import time from unittest.mock import patch -from aiohttp.client_exceptions import ClientError import pytest from homeassistant.components.youtube.const import CONF_CHANNELS, DOMAIN from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant +from homeassistant.exceptions import OAuth2TokenRequestConnectionError from homeassistant.helpers import device_registry as dr from homeassistant.helpers.config_entry_oauth2_flow import ( ImplementationUnavailableError, @@ -110,7 +110,7 @@ async def test_expired_token_refresh_client_error( with patch( "homeassistant.components.youtube.OAuth2Session.async_ensure_token_valid", - side_effect=ClientError, + side_effect=OAuth2TokenRequestConnectionError(domain=DOMAIN), ): await setup_integration() From 32fa3b35540ad71958effdaf59c5c6d6e95f1bd0 Mon Sep 17 00:00:00 2001 From: Josef Zweck Date: Mon, 7 Sep 2026 09:10:19 +0200 Subject: [PATCH 13/20] Fix flaky test in sonos (#181506) --- tests/components/sonos/test_init.py | 51 +++++++++++++++++++---------- 1 file changed, 33 insertions(+), 18 deletions(-) diff --git a/tests/components/sonos/test_init.py b/tests/components/sonos/test_init.py index bf06f2a76f2e9d..23331318758311 100644 --- a/tests/components/sonos/test_init.py +++ b/tests/components/sonos/test_init.py @@ -580,32 +580,47 @@ async def test_async_poll_manual_hosts_6( soco_1.renderingControl = Mock() soco_1.renderingControl.GetVolume = Mock() soco_1.renderingControl.GetVolume.side_effect = SonosUpdateError() - speaker_1_activity = SpeakerActivity(hass, soco_1) soco_2 = soco_factory.cache_mock(MockSoCo(), "10.10.10.2", "Bedroom") soco_2.renderingControl = Mock() soco_2.renderingControl.GetVolume = Mock() soco_2.renderingControl.GetVolume.side_effect = SonosUpdateError() - speaker_2_activity = SpeakerActivity(hass, soco_2) - with patch( - "homeassistant.components.sonos.DISCOVERY_INTERVAL" - ) as mock_discovery_interval: - # Speed up manual discovery interval so second iteration runs sooner - mock_discovery_interval.total_seconds = Mock(side_effect=[0.0, 60]) - await _setup_hass(hass) + await _setup_hass(hass) + await hass.async_block_till_done(wait_background_tasks=True) - assert "media_player.bedroom" in entity_registry.entities - assert "media_player.living_room" in entity_registry.entities + assert "media_player.bedroom" in entity_registry.entities + assert "media_player.living_room" in entity_registry.entities + bedroom_state = hass.states.get("media_player.bedroom") + assert bedroom_state is not None + assert bedroom_state.state == "unavailable" + living_room_state = hass.states.get("media_player.living_room") + assert living_room_state is not None + assert living_room_state.state == "unavailable" - with caplog.at_level(logging.DEBUG): - caplog.clear() - await hass.async_block_till_done() - assert "Activity on Living Room" not in caplog.text - assert "Activity on Bedroom" not in caplog.text - assert speaker_1_activity.call_count == 0 - assert speaker_2_activity.call_count == 0 + speaker_1_activity = SpeakerActivity(hass, soco_1) + speaker_2_activity = SpeakerActivity(hass, soco_2) + soco_1.renderingControl.GetVolume.reset_mock() + soco_2.renderingControl.GetVolume.reset_mock() - await hass.async_block_till_done(wait_background_tasks=True) + with ( + caplog.at_level(logging.DEBUG), + freeze_time(dt_util.utcnow()) as freezer, + ): + caplog.clear() + freezer.tick(DISCOVERY_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) + + soco_1.renderingControl.GetVolume.assert_called_once_with( + [("InstanceID", 0), ("Channel", "Master")], timeout=1 + ) + soco_2.renderingControl.GetVolume.assert_called_once_with( + [("InstanceID", 0), ("Channel", "Master")], timeout=1 + ) + assert "Activity on Living Room" not in caplog.text + assert "Activity on Bedroom" not in caplog.text + assert speaker_1_activity.call_count == 0 + assert speaker_2_activity.call_count == 0 async def test_async_poll_manual_hosts_skips_ping_for_disabled_device( From 88d5dab64d24bc2d7f4184687d0b19aa9e79b610 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Mon, 7 Sep 2026 09:19:40 +0200 Subject: [PATCH 14/20] Refactor tuya CZ/KG sensor descriptions (#181504) --- homeassistant/components/tuya/sensor.py | 236 +++++++++++++----------- 1 file changed, 131 insertions(+), 105 deletions(-) diff --git a/homeassistant/components/tuya/sensor.py b/homeassistant/components/tuya/sensor.py index b0638c10428c84..0268d3e0b60484 100644 --- a/homeassistant/components/tuya/sensor.py +++ b/homeassistant/components/tuya/sensor.py @@ -278,6 +278,137 @@ class TuyaSensorEntityDescription(SensorEntityDescription): key=DPCode.WATER_LEVEL, translation_key="water_level_state" ), ), + DeviceCategory.CZ: ( + TuyaSensorEntityDescription( + key=DPCode.CUR_CURRENT, + translation_key="current", + device_class=SensorDeviceClass.CURRENT, + state_class=SensorStateClass.MEASUREMENT, + suggested_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + ), + TuyaSensorEntityDescription( + key=DPCode.CUR_POWER, + translation_key="power", + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + ), + TuyaSensorEntityDescription( + key=DPCode.CUR_VOLTAGE, + translation_key="voltage", + device_class=SensorDeviceClass.VOLTAGE, + state_class=SensorStateClass.MEASUREMENT, + suggested_unit_of_measurement=UnitOfElectricPotential.VOLT, + ), + TuyaSensorEntityDescription( + key=DPCode.ADD_ELE, + translation_key="total_energy", + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + suggested_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + ), + TuyaSensorEntityDescription( + key=DPCode.PRO_ADD_ELE, + translation_key="total_production", + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + ), + TuyaSensorEntityDescription( + key=DPCode.DEVICE_STATE1, + translation_key="indexed_meter_status", + translation_placeholders={"index": "1"}, + ), + TuyaSensorEntityDescription( + key=DPCode.DEVICE_STATE2, + translation_key="indexed_meter_status", + translation_placeholders={"index": "2"}, + ), + TuyaSensorEntityDescription( + key=DPCode.CUR_CURRENT1, + translation_key="indexed_current", + translation_placeholders={"index": "1"}, + device_class=SensorDeviceClass.CURRENT, + state_class=SensorStateClass.MEASUREMENT, + suggested_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + ), + TuyaSensorEntityDescription( + key=DPCode.CUR_CURRENT2, + translation_key="indexed_current", + translation_placeholders={"index": "2"}, + device_class=SensorDeviceClass.CURRENT, + state_class=SensorStateClass.MEASUREMENT, + suggested_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + ), + TuyaSensorEntityDescription( + key=DPCode.CUR_POWER1, + translation_key="indexed_power", + translation_placeholders={"index": "1"}, + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + ), + TuyaSensorEntityDescription( + key=DPCode.CUR_POWER2, + translation_key="indexed_power", + translation_placeholders={"index": "2"}, + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + ), + TuyaSensorEntityDescription( + key=DPCode.CUR_VOLTAGE1, + translation_key="indexed_voltage", + translation_placeholders={"index": "1"}, + device_class=SensorDeviceClass.VOLTAGE, + state_class=SensorStateClass.MEASUREMENT, + suggested_unit_of_measurement=UnitOfElectricPotential.VOLT, + ), + TuyaSensorEntityDescription( + key=DPCode.CUR_VOLTAGE2, + translation_key="indexed_voltage", + translation_placeholders={"index": "2"}, + device_class=SensorDeviceClass.VOLTAGE, + state_class=SensorStateClass.MEASUREMENT, + suggested_unit_of_measurement=UnitOfElectricPotential.VOLT, + ), + TuyaSensorEntityDescription( + key=DPCode.TOTAL_ENERGY1, + translation_key="indexed_total_energy", + translation_placeholders={"index": "1"}, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + suggested_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + ), + TuyaSensorEntityDescription( + key=DPCode.TOTAL_ENERGY2, + translation_key="indexed_total_energy", + translation_placeholders={"index": "2"}, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + suggested_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + ), + TuyaSensorEntityDescription( + key=DPCode.TODAY_ACC_ENERGY1, + translation_key="indexed_energy_today", + translation_placeholders={"index": "1"}, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + suggested_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + ), + TuyaSensorEntityDescription( + key=DPCode.TODAY_ACC_ENERGY2, + translation_key="indexed_energy_today", + translation_placeholders={"index": "2"}, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + suggested_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + ), + TuyaSensorEntityDescription( + key=DPCode.ALL_ENERGY, + translation_key="total_energy", + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + suggested_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + ), + ), DeviceCategory.DGNBJ: ( TuyaSensorEntityDescription( key=DPCode.GAS_SENSOR_VALUE, @@ -1673,111 +1804,6 @@ class TuyaSensorEntityDescription(SensorEntityDescription): ), } -# Two-channel current transformer meters report a full set of electricity DPs -# per channel, plus a combined energy total for both channels. -DUAL_CHANNEL_METER_SENSORS: tuple[TuyaSensorEntityDescription, ...] = ( - TuyaSensorEntityDescription( - key=DPCode.DEVICE_STATE1, - translation_key="indexed_meter_status", - translation_placeholders={"index": "1"}, - ), - TuyaSensorEntityDescription( - key=DPCode.DEVICE_STATE2, - translation_key="indexed_meter_status", - translation_placeholders={"index": "2"}, - ), - TuyaSensorEntityDescription( - key=DPCode.CUR_CURRENT1, - translation_key="indexed_current", - translation_placeholders={"index": "1"}, - device_class=SensorDeviceClass.CURRENT, - state_class=SensorStateClass.MEASUREMENT, - suggested_unit_of_measurement=UnitOfElectricCurrent.AMPERE, - ), - TuyaSensorEntityDescription( - key=DPCode.CUR_CURRENT2, - translation_key="indexed_current", - translation_placeholders={"index": "2"}, - device_class=SensorDeviceClass.CURRENT, - state_class=SensorStateClass.MEASUREMENT, - suggested_unit_of_measurement=UnitOfElectricCurrent.AMPERE, - ), - TuyaSensorEntityDescription( - key=DPCode.CUR_POWER1, - translation_key="indexed_power", - translation_placeholders={"index": "1"}, - device_class=SensorDeviceClass.POWER, - state_class=SensorStateClass.MEASUREMENT, - ), - TuyaSensorEntityDescription( - key=DPCode.CUR_POWER2, - translation_key="indexed_power", - translation_placeholders={"index": "2"}, - device_class=SensorDeviceClass.POWER, - state_class=SensorStateClass.MEASUREMENT, - ), - TuyaSensorEntityDescription( - key=DPCode.CUR_VOLTAGE1, - translation_key="indexed_voltage", - translation_placeholders={"index": "1"}, - device_class=SensorDeviceClass.VOLTAGE, - state_class=SensorStateClass.MEASUREMENT, - suggested_unit_of_measurement=UnitOfElectricPotential.VOLT, - ), - TuyaSensorEntityDescription( - key=DPCode.CUR_VOLTAGE2, - translation_key="indexed_voltage", - translation_placeholders={"index": "2"}, - device_class=SensorDeviceClass.VOLTAGE, - state_class=SensorStateClass.MEASUREMENT, - suggested_unit_of_measurement=UnitOfElectricPotential.VOLT, - ), - TuyaSensorEntityDescription( - key=DPCode.TOTAL_ENERGY1, - translation_key="indexed_total_energy", - translation_placeholders={"index": "1"}, - device_class=SensorDeviceClass.ENERGY, - state_class=SensorStateClass.TOTAL_INCREASING, - suggested_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, - ), - TuyaSensorEntityDescription( - key=DPCode.TOTAL_ENERGY2, - translation_key="indexed_total_energy", - translation_placeholders={"index": "2"}, - device_class=SensorDeviceClass.ENERGY, - state_class=SensorStateClass.TOTAL_INCREASING, - suggested_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, - ), - TuyaSensorEntityDescription( - key=DPCode.TODAY_ACC_ENERGY1, - translation_key="indexed_energy_today", - translation_placeholders={"index": "1"}, - device_class=SensorDeviceClass.ENERGY, - state_class=SensorStateClass.TOTAL_INCREASING, - suggested_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, - ), - TuyaSensorEntityDescription( - key=DPCode.TODAY_ACC_ENERGY2, - translation_key="indexed_energy_today", - translation_placeholders={"index": "2"}, - device_class=SensorDeviceClass.ENERGY, - state_class=SensorStateClass.TOTAL_INCREASING, - suggested_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, - ), - TuyaSensorEntityDescription( - key=DPCode.ALL_ENERGY, - translation_key="total_energy", - device_class=SensorDeviceClass.ENERGY, - state_class=SensorStateClass.TOTAL_INCREASING, - suggested_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, - ), -) - -# Socket (duplicate of `kg`, plus the two-channel meter DPs) -SENSORS[DeviceCategory.CZ] = ( - *SENSORS[DeviceCategory.KG], - *DUAL_CHANNEL_METER_SENSORS, -) # Smart Camera - Low power consumption camera (duplicate of `sp`) SENSORS[DeviceCategory.DGHSXJ] = SENSORS[DeviceCategory.SP] From 9afabaaba153af4f3d453da12ed434f5ca6dc206 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 7 Sep 2026 09:38:44 +0200 Subject: [PATCH 15/20] Trim cached orjson fragments kept by registries (#181240) Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- homeassistant/helpers/area_registry.py | 32 +++-- homeassistant/helpers/device_registry.py | 144 +++++++++++----------- homeassistant/helpers/entity_registry.py | 148 ++++++++++++----------- homeassistant/helpers/json.py | 22 ++++ tests/helpers/test_json.py | 70 +++++++++++ 5 files changed, 254 insertions(+), 162 deletions(-) diff --git a/homeassistant/helpers/area_registry.py b/homeassistant/helpers/area_registry.py index 1835fb6af80f28..6da7608fcf3628 100644 --- a/homeassistant/helpers/area_registry.py +++ b/homeassistant/helpers/area_registry.py @@ -14,7 +14,7 @@ from homeassistant.util.hass_dict import HassKey from . import device_registry as dr -from .json import json_bytes, json_fragment +from .json import cached_json_fragment, json_fragment from .normalized_name_base_registry import ( NormalizedNameBaseRegistryEntry, NormalizedNameBaseRegistryItems, @@ -87,22 +87,20 @@ class AreaEntry(NormalizedNameBaseRegistryEntry): @under_cached_property def json_fragment(self) -> json_fragment: """Return a JSON representation of this AreaEntry.""" - return json_fragment( - json_bytes( - { - "aliases": list(self.aliases), - "area_id": self.id, - "floor_id": self.floor_id, - "humidity_entity_id": self.humidity_entity_id, - "icon": self.icon, - "labels": list(self.labels), - "name": self.name, - "picture": self.picture, - "temperature_entity_id": self.temperature_entity_id, - "created_at": self.created_at.timestamp(), - "modified_at": self.modified_at.timestamp(), - } - ) + return cached_json_fragment( + { + "aliases": list(self.aliases), + "area_id": self.id, + "floor_id": self.floor_id, + "humidity_entity_id": self.humidity_entity_id, + "icon": self.icon, + "labels": list(self.labels), + "name": self.name, + "picture": self.picture, + "temperature_entity_id": self.temperature_entity_id, + "created_at": self.created_at.timestamp(), + "modified_at": self.modified_at.timestamp(), + } ) diff --git a/homeassistant/helpers/device_registry.py b/homeassistant/helpers/device_registry.py index e08aec135e6d88..a0974e7f1b27e0 100644 --- a/homeassistant/helpers/device_registry.py +++ b/homeassistant/helpers/device_registry.py @@ -52,7 +52,13 @@ get_integration_frame, report_usage, ) -from .json import JSON_DUMP, find_paths_unserializable_data, json_bytes, json_fragment +from .json import ( + JSON_DUMP, + cached_json_bytes, + cached_json_fragment, + find_paths_unserializable_data, + json_fragment, +) from .registry import BaseRegistry, BaseRegistryItems, RegistryIndexType from .typing import UNDEFINED, UndefinedType @@ -435,7 +441,7 @@ def json_repr(self) -> bytes | None: """Return a cached JSON representation of the entry.""" try: dict_repr = self.dict_repr - return json_bytes(dict_repr) + return cached_json_bytes(dict_repr) except ValueError, TypeError: _LOGGER.error( "Unable to serialize entry %s to JSON. Bad data found at %s", @@ -564,39 +570,35 @@ def dict_repr(self) -> dict[str, Any]: @under_cached_property def as_storage_fragment(self) -> json_fragment: """Return a json fragment for storage.""" - return json_fragment( - json_bytes( - { - "area_id": self.area_id, - "config_entry_id": self.config_entry_id, - "config_subentry_id": self.config_subentry_id, - "configuration_url": self.configuration_url, - "connections": list(self.connections), - "created_at": self.created_at, - "disabled_by": self.disabled_by, - "entry_type": self.entry_type, - "hw_version": self.hw_version, - "id": self.id, - "identifiers": list(self.identifiers), - "labels": list(self.labels), - "composite_device_id": self.composite_device_id, - "composite_primary_config_entry": ( - self.composite_primary_config_entry - ), - "split_at": self.split_at, - "manufacturer": self.manufacturer, - "model": self.model, - "model_id": self.model_id, - "modified_at": self.modified_at, - "name_by_user": self.name_by_user, - "name": self.name, - "has_composite_identifiers": (self.has_composite_identifiers), - "primary_config_entry": self.primary_config_entry, - "serial_number": self.serial_number, - "sw_version": self.sw_version, - "via_device_id": self.via_device_id, - } - ) + return cached_json_fragment( + { + "area_id": self.area_id, + "config_entry_id": self.config_entry_id, + "config_subentry_id": self.config_subentry_id, + "configuration_url": self.configuration_url, + "connections": list(self.connections), + "created_at": self.created_at, + "disabled_by": self.disabled_by, + "entry_type": self.entry_type, + "hw_version": self.hw_version, + "id": self.id, + "identifiers": list(self.identifiers), + "labels": list(self.labels), + "composite_device_id": self.composite_device_id, + "composite_primary_config_entry": self.composite_primary_config_entry, + "split_at": self.split_at, + "manufacturer": self.manufacturer, + "model": self.model, + "model_id": self.model_id, + "modified_at": self.modified_at, + "name_by_user": self.name_by_user, + "name": self.name, + "has_composite_identifiers": (self.has_composite_identifiers), + "primary_config_entry": self.primary_config_entry, + "serial_number": self.serial_number, + "sw_version": self.sw_version, + "via_device_id": self.via_device_id, + } ) @property @@ -686,23 +688,21 @@ def dict_repr(self) -> dict[str, Any]: @under_cached_property def as_storage_fragment(self) -> json_fragment: """Return a json fragment for storage.""" - return json_fragment( - json_bytes( - { - "area_id": self.area_id, - "config_entry_id": self.config_entry_id, - "config_subentry_id": self.config_subentry_id, - "created_at": self.created_at, - "disabled_by": self.disabled_by, - "id": self.id, - "identifiers": list(self.identifiers), - "labels": list(self.labels), - "modified_at": self.modified_at, - "name_by_user": self.name_by_user, - "name": self.name, - "parent_device_id": self.parent_device_id, - } - ) + return cached_json_fragment( + { + "area_id": self.area_id, + "config_entry_id": self.config_entry_id, + "config_subentry_id": self.config_subentry_id, + "created_at": self.created_at, + "disabled_by": self.disabled_by, + "id": self.id, + "identifiers": list(self.identifiers), + "labels": list(self.labels), + "modified_at": self.modified_at, + "name_by_user": self.name_by_user, + "name": self.name, + "parent_device_id": self.parent_device_id, + } ) @@ -849,27 +849,25 @@ def to_child_device_entry( @under_cached_property def as_storage_fragment(self) -> json_fragment: """Return a json fragment for storage.""" - return json_fragment( - json_bytes( - { - "area_id": self.area_id, - "config_entry_id": self.config_entry_id, - "config_subentry_id": self.config_subentry_id, - "connections": list(self.connections), - "created_at": self.created_at, - "disabled_by": self.disabled_by - if self.disabled_by is not UNDEFINED - else None, - "disabled_by_undefined": self.disabled_by is UNDEFINED, - "identifiers": list(self.identifiers), - "id": self.id, - "labels": list(self.labels), - "modified_at": self.modified_at, - "name_by_user": self.name_by_user, - "orphaned_timestamp": self.orphaned_timestamp, - "domain": self.domain, - } - ) + return cached_json_fragment( + { + "area_id": self.area_id, + "config_entry_id": self.config_entry_id, + "config_subentry_id": self.config_subentry_id, + "connections": list(self.connections), + "created_at": self.created_at, + "disabled_by": self.disabled_by + if self.disabled_by is not UNDEFINED + else None, + "disabled_by_undefined": self.disabled_by is UNDEFINED, + "identifiers": list(self.identifiers), + "id": self.id, + "labels": list(self.labels), + "modified_at": self.modified_at, + "name_by_user": self.name_by_user, + "orphaned_timestamp": self.orphaned_timestamp, + "domain": self.domain, + } ) diff --git a/homeassistant/helpers/entity_registry.py b/homeassistant/helpers/entity_registry.py index 1d9f4dce834140..a2a2ce1ca29588 100644 --- a/homeassistant/helpers/entity_registry.py +++ b/homeassistant/helpers/entity_registry.py @@ -53,7 +53,13 @@ EventDeviceRegistryUpdatedData, ) from .frame import ReportBehavior, report_usage -from .json import JSON_DUMP, find_paths_unserializable_data, json_bytes, json_fragment +from .json import ( + JSON_DUMP, + cached_json_bytes, + cached_json_fragment, + find_paths_unserializable_data, + json_fragment, +) from .registry import BaseRegistry, BaseRegistryItems, RegistryIndexType from .singleton import singleton from .typing import UNDEFINED, UndefinedType @@ -319,7 +325,9 @@ def display_json_repr(self) -> bytes | None: """ try: dict_repr = self._as_display_dict - json_repr: bytes | None = json_bytes(dict_repr) if dict_repr else None + json_repr: bytes | None = ( + cached_json_bytes(dict_repr) if dict_repr else None + ) except ValueError, TypeError: _LOGGER.error( "Unable to serialize entry %s to JSON. Bad data found at %s", @@ -386,7 +394,7 @@ def partial_json_repr(self) -> bytes | None: """Return a cached partial JSON representation of the entry.""" try: dict_repr = self.as_partial_dict - return json_bytes(dict_repr) + return cached_json_bytes(dict_repr) except ValueError, TypeError: _LOGGER.error( "Unable to serialize entry %s to JSON. Bad data found at %s", @@ -400,43 +408,41 @@ def partial_json_repr(self) -> bytes | None: @under_cached_property def as_storage_fragment(self) -> json_fragment: """Return a json fragment for storage.""" - return json_fragment( - json_bytes( - { - "aliases": self.compat_aliases, - "aliases_v2": _serialize_aliases(self.aliases), - "area_id": self.area_id, - "categories": self.categories, - "capabilities": self.capabilities, - "config_entry_id": self.config_entry_id, - "config_subentry_id": self.config_subentry_id, - "created_at": self.created_at, - "device_class": self.device_class, - "device_id": self.device_id, - "disabled_by": self.disabled_by, - "entity_category": self.entity_category, - "entity_id": self.entity_id, - "hidden_by": self.hidden_by, - "icon": self.icon, - "id": self.id, - "has_entity_name": self.has_entity_name, - "labels": list(self.labels), - "modified_at": self.modified_at, - "name": self.name, - "object_id_base": self.object_id_base, - "options": self.options, - "original_device_class": self.original_device_class, - "original_icon": self.original_icon, - "original_name": self.original_name, - "platform": self.platform, - "suggested_object_id": self.suggested_object_id, - "supported_features": self.supported_features, - "translation_key": self.translation_key, - "unique_id": self.unique_id, - "previous_unique_id": self.previous_unique_id, - "unit_of_measurement": self.unit_of_measurement, - } - ) + return cached_json_fragment( + { + "aliases": self.compat_aliases, + "aliases_v2": _serialize_aliases(self.aliases), + "area_id": self.area_id, + "categories": self.categories, + "capabilities": self.capabilities, + "config_entry_id": self.config_entry_id, + "config_subentry_id": self.config_subentry_id, + "created_at": self.created_at, + "device_class": self.device_class, + "device_id": self.device_id, + "disabled_by": self.disabled_by, + "entity_category": self.entity_category, + "entity_id": self.entity_id, + "hidden_by": self.hidden_by, + "icon": self.icon, + "id": self.id, + "has_entity_name": self.has_entity_name, + "labels": list(self.labels), + "modified_at": self.modified_at, + "name": self.name, + "object_id_base": self.object_id_base, + "options": self.options, + "original_device_class": self.original_device_class, + "original_icon": self.original_icon, + "original_name": self.original_name, + "platform": self.platform, + "suggested_object_id": self.suggested_object_id, + "supported_features": self.supported_features, + "translation_key": self.translation_key, + "unique_id": self.unique_id, + "previous_unique_id": self.previous_unique_id, + "unit_of_measurement": self.unit_of_measurement, + } ) @callback @@ -738,38 +744,36 @@ def _domain_default(self) -> str: @under_cached_property def as_storage_fragment(self) -> json_fragment: """Return a json fragment for storage.""" - return json_fragment( - json_bytes( - { - "aliases": self.compat_aliases, - "aliases_v2": _serialize_aliases(self.aliases), - "area_id": self.area_id, - "categories": self.categories, - "config_entry_id": self.config_entry_id, - "config_subentry_id": self.config_subentry_id, - "created_at": self.created_at, - "device_class": self.device_class, - "disabled_by": self.disabled_by - if self.disabled_by is not UNDEFINED - else None, - "disabled_by_undefined": self.disabled_by is UNDEFINED, - "entity_id": self.entity_id, - "hidden_by": self.hidden_by - if self.hidden_by is not UNDEFINED - else None, - "hidden_by_undefined": self.hidden_by is UNDEFINED, - "icon": self.icon, - "id": self.id, - "labels": list(self.labels), - "modified_at": self.modified_at, - "name": self.name, - "options": self.options if self.options is not UNDEFINED else {}, - "options_undefined": self.options is UNDEFINED, - "orphaned_timestamp": self.orphaned_timestamp, - "platform": self.platform, - "unique_id": self.unique_id, - } - ) + return cached_json_fragment( + { + "aliases": self.compat_aliases, + "aliases_v2": _serialize_aliases(self.aliases), + "area_id": self.area_id, + "categories": self.categories, + "config_entry_id": self.config_entry_id, + "config_subentry_id": self.config_subentry_id, + "created_at": self.created_at, + "device_class": self.device_class, + "disabled_by": self.disabled_by + if self.disabled_by is not UNDEFINED + else None, + "disabled_by_undefined": self.disabled_by is UNDEFINED, + "entity_id": self.entity_id, + "hidden_by": self.hidden_by + if self.hidden_by is not UNDEFINED + else None, + "hidden_by_undefined": self.hidden_by is UNDEFINED, + "icon": self.icon, + "id": self.id, + "labels": list(self.labels), + "modified_at": self.modified_at, + "name": self.name, + "options": self.options if self.options is not UNDEFINED else {}, + "options_undefined": self.options is UNDEFINED, + "orphaned_timestamp": self.orphaned_timestamp, + "platform": self.platform, + "unique_id": self.unique_id, + } ) diff --git a/homeassistant/helpers/json.py b/homeassistant/helpers/json.py index 3ea52348a095c9..0589490d274854 100644 --- a/homeassistant/helpers/json.py +++ b/homeassistant/helpers/json.py @@ -116,6 +116,28 @@ def json_bytes_strip_null(data: Any) -> bytes: json_fragment = orjson.Fragment +def cached_json_bytes(data: Any) -> bytes: + """Return json bytes right-sized for long-term caching. + + orjson over-allocates the returned bytes buffer and does not shrink it: the + logical length is set but the capacity is rounded up to a power of two (at + least a few KiB), so bytes cached for the lifetime of a long-lived object + retain several KiB of unused buffer. + """ + # Drop orjson's over-allocated slack with help of a memoryview. + return bytes(memoryview(json_bytes(data))) + + +def cached_json_fragment(data: Any) -> orjson.Fragment: + """Return a json fragment right-sized for long-term caching. + + Wraps the same right-sized bytes as cached_json_bytes; the body is inlined + rather than calling it to avoid an extra function call on this hot path. + """ + # Drop orjson's over-allocated slack with help of a memoryview. + return orjson.Fragment(bytes(memoryview(json_bytes(data)))) + + def json_dumps(data: Any) -> str: r"""Dump json string. diff --git a/tests/helpers/test_json.py b/tests/helpers/test_json.py index 9807e2a1552609..ba43f0341706f9 100644 --- a/tests/helpers/test_json.py +++ b/tests/helpers/test_json.py @@ -1,12 +1,15 @@ """Test Home Assistant remote methods and classes.""" +from collections.abc import Callable import datetime from functools import partial +import gc import json import math import os from pathlib import Path import time +import tracemalloc from typing import Any, NamedTuple from unittest.mock import Mock, patch @@ -16,7 +19,10 @@ from homeassistant.helpers.json import ( ExtendedJSONEncoder, JSONEncoder as DefaultHASSJSONEncoder, + cached_json_bytes, + cached_json_fragment, find_paths_unserializable_data, + json_bytes, json_bytes_sorted, json_bytes_strip_null, json_dumps, @@ -208,6 +214,70 @@ def json_fragment(self): ) +def test_cached_json_fragment() -> None: + """Test cached_json_fragment serializes identically to a plain fragment.""" + data = {"a": 1, "b": [1, 2, 3], "c": {"nested": True}, "d": None} + + fragment = cached_json_fragment(data) + assert isinstance(fragment, json_fragment) + assert json_dumps([fragment]) == json_dumps([json_fragment(json_bytes(data))]) + assert ( + json_dumps([fragment]) == '[{"a":1,"b":[1,2,3],"c":{"nested":true},"d":null}]' + ) + + +def test_cached_json_bytes() -> None: + """Test cached_json_bytes serializes identically to json_bytes.""" + data = {"a": 1, "b": [1, 2, 3], "c": {"nested": True}, "d": None} + + assert cached_json_bytes(data) == json_bytes(data) + assert ( + cached_json_bytes(data) == b'{"a":1,"b":[1,2,3],"c":{"nested":true},"d":null}' + ) + + +@pytest.mark.parametrize( + "cached_serializer", + [cached_json_bytes, cached_json_fragment], + ids=["cached_json_bytes", "cached_json_fragment"], +) +def test_cached_json_helpers_trim_buffer( + cached_serializer: Callable[[Any], object], +) -> None: + """Test the cached_json_* helpers cache right-sized bytes, not orjson's slack. + + orjson.dumps returns bytes whose backing buffer is rounded up to a power of + two and not shrunk; the helpers copy them to a right-sized buffer. Without + that copy the cached value would retain the full over-allocated buffer + (several KiB even for a small payload), which is the memory regression this + guards against. + + The waste is invisible to normal object inspection: sys.getsizeof() reports + the logical length, not the backing buffer, and orjson.Fragment exposes no way + to reach the bytes it wraps, so the retained allocation can only be observed + via tracemalloc. + """ + data = {f"key_{index}": "value" * 5 for index in range(40)} + serialized_size = len(json_bytes(data)) + + tracemalloc.start() + try: + # clear_traces resets the baseline to zero so pre-existing garbage from + # the test session is not counted; the transient over-allocated buffer is + # freed by refcounting before get_traced_memory, leaving only `cached`. + gc.collect() + tracemalloc.clear_traces() + cached = cached_serializer(data) + retained, _ = tracemalloc.get_traced_memory() + finally: + tracemalloc.stop() + + assert cached is not None # keep alive until measured + # The cache holds ~the serialized size; without the copy it would hold + # orjson's oversized power-of-two buffer, which is far larger. + assert retained < serialized_size * 1.5 + + def test_json_bytes_strip_null() -> None: """Test stripping nul from strings.""" From b29766a1381fa14d11fb75dd58afb586e0c80a6f Mon Sep 17 00:00:00 2001 From: Thomas Munzer Date: Mon, 7 Sep 2026 09:41:36 +0200 Subject: [PATCH 16/20] Add Circuit Breaker (ZNJDQ) Fixture in Tuya integration (#181509) Co-authored-by: Thomas Munzer --- .../tuya/fixtures/znjdq_au6dqazvkxqnpaak.json | 269 ++++++++++++++++++ .../components/tuya/snapshots/test_init.ambr | 90 ++++-- 2 files changed, 329 insertions(+), 30 deletions(-) create mode 100644 tests/components/tuya/fixtures/znjdq_au6dqazvkxqnpaak.json diff --git a/tests/components/tuya/fixtures/znjdq_au6dqazvkxqnpaak.json b/tests/components/tuya/fixtures/znjdq_au6dqazvkxqnpaak.json new file mode 100644 index 00000000000000..6d09c65538ef58 --- /dev/null +++ b/tests/components/tuya/fixtures/znjdq_au6dqazvkxqnpaak.json @@ -0,0 +1,269 @@ +{ + "endpoint": "https://apigw.tuyaeu.com", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "name": "Smart Circuit Breaker", + "category": "znjdq", + "product_id": "au6dqazvkxqnpaak", + "product_name": "63A Smart Circuit Breaker", + "online": true, + "sub": false, + "time_zone": "+02:00", + "active_time": "2026-09-05T15:54:33+00:00", + "create_time": "2026-09-05T15:54:33+00:00", + "update_time": "2026-09-05T15:54:33+00:00", + "function": { + "switch_1": { + "type": "Boolean", + "value": "{}" + }, + "countdown_1": { + "type": "Integer", + "value": "{\"unit\":\"s\",\"min\":0,\"max\":86400,\"scale\":0,\"step\":1}" + }, + "relay_status": { + "type": "Enum", + "value": "{\"range\":[\"off\",\"on\",\"memory\"]}" + }, + "light_mode": { + "type": "Enum", + "value": "{\"range\":[\"relay\",\"pos\",\"none\",\"on\"]}" + }, + "child_lock": { + "type": "Boolean", + "value": "{}" + } + }, + "local_strategy": { + "1": { + "value_convert": "default", + "status_code": "switch_1", + "config_item": { + "statusFormat": "{\"switch_1\":\"$\"}", + "valueDesc": "{}", + "valueType": "Boolean", + "enumMappingMap": {}, + "pid": "au6dqazvkxqnpaak" + } + }, + "9": { + "value_convert": "default", + "status_code": "countdown_1", + "config_item": { + "statusFormat": "{\"countdown_1\":\"$\"}", + "valueDesc": "{\"unit\":\"s\",\"min\":0,\"max\":86400,\"scale\":0,\"step\":1}", + "valueType": "Integer", + "enumMappingMap": {}, + "pid": "au6dqazvkxqnpaak" + } + }, + "17": { + "value_convert": "default", + "status_code": "add_ele", + "config_item": { + "statusFormat": "{\"add_ele\":\"$\"}", + "valueDesc": "{\"unit\":\"\",\"min\":0,\"max\":9999999,\"scale\":3,\"step\":100}", + "valueType": "Integer", + "enumMappingMap": {}, + "pid": "au6dqazvkxqnpaak" + } + }, + "18": { + "value_convert": "default", + "status_code": "cur_current", + "config_item": { + "statusFormat": "{\"cur_current\":\"$\"}", + "valueDesc": "{\"unit\":\"mA\",\"min\":0,\"max\":99999,\"scale\":0,\"step\":1}", + "valueType": "Integer", + "enumMappingMap": {}, + "pid": "au6dqazvkxqnpaak" + } + }, + "19": { + "value_convert": "default", + "status_code": "cur_power", + "config_item": { + "statusFormat": "{\"cur_power\":\"$\"}", + "valueDesc": "{\"unit\":\"W\",\"min\":0,\"max\":260000,\"scale\":1,\"step\":1}", + "valueType": "Integer", + "enumMappingMap": {}, + "pid": "au6dqazvkxqnpaak" + } + }, + "20": { + "value_convert": "default", + "status_code": "cur_voltage", + "config_item": { + "statusFormat": "{\"cur_voltage\":\"$\"}", + "valueDesc": "{\"unit\":\"V\",\"min\":0,\"max\":9999,\"scale\":1,\"step\":1}", + "valueType": "Integer", + "enumMappingMap": {}, + "pid": "au6dqazvkxqnpaak" + } + }, + "22": { + "value_convert": "default", + "status_code": "voltage_coe", + "config_item": { + "statusFormat": "{\"voltage_coe\":\"$\"}", + "valueDesc": "{\"min\":0,\"max\":1000000,\"scale\":0,\"step\":1}", + "valueType": "Integer", + "enumMappingMap": {}, + "pid": "au6dqazvkxqnpaak" + } + }, + "23": { + "value_convert": "default", + "status_code": "electric_coe", + "config_item": { + "statusFormat": "{\"electric_coe\":\"$\"}", + "valueDesc": "{\"min\":0,\"max\":1000000,\"scale\":0,\"step\":1}", + "valueType": "Integer", + "enumMappingMap": {}, + "pid": "au6dqazvkxqnpaak" + } + }, + "24": { + "value_convert": "default", + "status_code": "power_coe", + "config_item": { + "statusFormat": "{\"power_coe\":\"$\"}", + "valueDesc": "{\"min\":0,\"max\":1000000,\"scale\":0,\"step\":1}", + "valueType": "Integer", + "enumMappingMap": {}, + "pid": "au6dqazvkxqnpaak" + } + }, + "25": { + "value_convert": "default", + "status_code": "electricity_coe", + "config_item": { + "statusFormat": "{\"electricity_coe\":\"$\"}", + "valueDesc": "{\"min\":0,\"max\":1000000,\"scale\":0,\"step\":1}", + "valueType": "Integer", + "enumMappingMap": {}, + "pid": "au6dqazvkxqnpaak" + } + }, + "38": { + "value_convert": "default", + "status_code": "relay_status", + "config_item": { + "statusFormat": "{\"relay_status\":\"$\"}", + "valueDesc": "{\"range\":[\"off\",\"on\",\"memory\"]}", + "valueType": "Enum", + "enumMappingMap": {}, + "pid": "au6dqazvkxqnpaak" + } + }, + "40": { + "value_convert": "default", + "status_code": "light_mode", + "config_item": { + "statusFormat": "{\"light_mode\":\"$\"}", + "valueDesc": "{\"range\":[\"relay\",\"pos\",\"none\",\"on\"]}", + "valueType": "Enum", + "enumMappingMap": {}, + "pid": "au6dqazvkxqnpaak" + } + }, + "41": { + "value_convert": "default", + "status_code": "child_lock", + "config_item": { + "statusFormat": "{\"child_lock\":\"$\"}", + "valueDesc": "{}", + "valueType": "Boolean", + "enumMappingMap": {}, + "pid": "au6dqazvkxqnpaak" + } + } + }, + "status_range": { + "switch_1": { + "type": "Boolean", + "value": "{}", + "report_type": null + }, + "countdown_1": { + "type": "Integer", + "value": "{\"unit\":\"s\",\"min\":0,\"max\":86400,\"scale\":0,\"step\":1}", + "report_type": null + }, + "add_ele": { + "type": "Integer", + "value": "{\"unit\":\"\",\"min\":0,\"max\":9999999,\"scale\":3,\"step\":100}", + "report_type": "sum" + }, + "cur_current": { + "type": "Integer", + "value": "{\"unit\":\"mA\",\"min\":0,\"max\":99999,\"scale\":0,\"step\":1}", + "report_type": null + }, + "cur_power": { + "type": "Integer", + "value": "{\"unit\":\"W\",\"min\":0,\"max\":260000,\"scale\":1,\"step\":1}", + "report_type": null + }, + "cur_voltage": { + "type": "Integer", + "value": "{\"unit\":\"V\",\"min\":0,\"max\":9999,\"scale\":1,\"step\":1}", + "report_type": null + }, + "voltage_coe": { + "type": "Integer", + "value": "{\"min\":0,\"max\":1000000,\"scale\":0,\"step\":1}", + "report_type": null + }, + "electric_coe": { + "type": "Integer", + "value": "{\"min\":0,\"max\":1000000,\"scale\":0,\"step\":1}", + "report_type": null + }, + "power_coe": { + "type": "Integer", + "value": "{\"min\":0,\"max\":1000000,\"scale\":0,\"step\":1}", + "report_type": null + }, + "electricity_coe": { + "type": "Integer", + "value": "{\"min\":0,\"max\":1000000,\"scale\":0,\"step\":1}", + "report_type": null + }, + "relay_status": { + "type": "Enum", + "value": "{\"range\":[\"off\",\"on\",\"memory\"]}", + "report_type": null + }, + "light_mode": { + "type": "Enum", + "value": "{\"range\":[\"relay\",\"pos\",\"none\",\"on\"]}", + "report_type": null + }, + "child_lock": { + "type": "Boolean", + "value": "{}", + "report_type": null + } + }, + "status": { + "switch_1": true, + "countdown_1": 0, + "add_ele": 7, + "cur_current": 258, + "cur_power": 143, + "cur_voltage": 2286, + "voltage_coe": 15950, + "electric_coe": 12638, + "power_coe": 3126, + "electricity_coe": 2683, + "relay_status": "memory", + "light_mode": "relay", + "child_lock": false + }, + "set_up": false, + "support_local": true, + "quirk": null, + "warnings": null +} diff --git a/tests/components/tuya/snapshots/test_init.ambr b/tests/components/tuya/snapshots/test_init.ambr index 43f740196fe052..775c1434e1264b 100644 --- a/tests/components/tuya/snapshots/test_init.ambr +++ b/tests/components/tuya/snapshots/test_init.ambr @@ -3269,6 +3269,36 @@ 'via_device_id': None, }) # --- +# name: test_device_registry[eikafwrmuhdvizruqzktk] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entry_id': , + 'config_subentry_id': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'tuya', + 'eikafwrmuhdvizruqzktk', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': 'VITAL+ (unsupported)', + 'model_id': 'urzivdhumrwfakie', + 'name': 'VITAL+', + 'name_by_user': None, + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- # name: test_device_registry[ej2zsznihehztkzqcaderarfni] DeviceRegistryEntrySnapshot({ 'area_id': None, @@ -5039,6 +5069,36 @@ 'via_device_id': None, }) # --- +# name: test_device_registry[kaapnqxkvzaqd6uaqdjnz] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entry_id': , + 'config_subentry_id': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'tuya', + 'kaapnqxkvzaqd6uaqdjnz', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': '63A Smart Circuit Breaker (unsupported)', + 'model_id': 'au6dqazvkxqnpaak', + 'name': 'Smart Circuit Breaker', + 'name_by_user': None, + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- # name: test_device_registry[kcdngswaxs8hm52bnocfw] DeviceRegistryEntrySnapshot({ 'area_id': None, @@ -8219,36 +8279,6 @@ 'via_device_id': None, }) # --- -# name: test_device_registry[eikafwrmuhdvizruqzktk] - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entry_id': , - 'config_subentry_id': , - 'configuration_url': None, - 'connections': set({ - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'tuya', - 'eikafwrmuhdvizruqzktk', - ), - }), - 'labels': set({ - }), - 'manufacturer': 'Tuya', - 'model': 'VITAL+ (unsupported)', - 'model_id': 'urzivdhumrwfakie', - 'name': 'VITAL+', - 'name_by_user': None, - 'serial_number': None, - 'sw_version': None, - 'via_device_id': None, - }) -# --- # name: test_device_registry[uvh6oeqrfliovfiwzc] DeviceRegistryEntrySnapshot({ 'area_id': None, From dac6307b57446e55463692a9bad32fbe8226d9f3 Mon Sep 17 00:00:00 2001 From: David Wu <133224895+David-Wu1119@users.noreply.github.com> Date: Mon, 7 Sep 2026 01:03:53 -0700 Subject: [PATCH 17/20] Type the dlna_dms domain data with a HassKey (#181100) --- homeassistant/components/dlna_dms/const.py | 10 +++++++++- homeassistant/components/dlna_dms/dms.py | 19 ++++++++++++------- 2 files changed, 21 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/dlna_dms/const.py b/homeassistant/components/dlna_dms/const.py index 4bc3d58d079a4a..7b7a58872ec683 100644 --- a/homeassistant/components/dlna_dms/const.py +++ b/homeassistant/components/dlna_dms/const.py @@ -2,15 +2,23 @@ from collections.abc import Mapping import logging -from typing import Final +from typing import TYPE_CHECKING, Final from homeassistant.components.media_player import MediaClass +from homeassistant.util.hass_dict import HassKey + +if TYPE_CHECKING: + from .dms import DlnaDmsData LOGGER = logging.getLogger(__package__) DOMAIN: Final = "dlna_dms" DEFAULT_NAME: Final = "DLNA Media Server" +# One DlnaDmsData holds the device and source registries for every config +# entry, so it is shared rather than owned by any one entry. +DOMAIN_DATA: HassKey[DlnaDmsData] = HassKey(DOMAIN) + CONF_SOURCE_ID: Final = "source_id" CONFIG_VERSION: Final = 1 diff --git a/homeassistant/components/dlna_dms/dms.py b/homeassistant/components/dlna_dms/dms.py index 7133c6f86a50c4..63ed280e9aba5f 100644 --- a/homeassistant/components/dlna_dms/dms.py +++ b/homeassistant/components/dlna_dms/dms.py @@ -1,12 +1,11 @@ """Wrapper for media_source around async_upnp_client's DmsDevice .""" -# pylint: disable=home-assistant-use-runtime-data # Uses legacy hass.data[DOMAIN] pattern import asyncio from collections.abc import Callable, Coroutine from dataclasses import dataclass from enum import StrEnum import functools -from typing import Any, cast +from typing import Any from async_upnp_client.aiohttp import AiohttpSessionRequester from async_upnp_client.client import UpnpRequester @@ -37,6 +36,7 @@ DLNA_RESOLVE_FILTER, DLNA_SORT_CRITERIA, DOMAIN, + DOMAIN_DATA, LOGGER, MEDIA_CLASS_MAP, PATH_OBJECT_ID_FLAG, @@ -91,12 +91,17 @@ async def async_unload_entry(self, config_entry: ConfigEntry) -> bool: @callback def get_domain_data(hass: HomeAssistant) -> DlnaDmsData: - """Obtain this integration's domain data, creating it if needed.""" - if DOMAIN in hass.data: - return cast(DlnaDmsData, hass.data[DOMAIN]) + """Obtain this integration's domain data, creating it if needed. - data = DlnaDmsData(hass) - hass.data[DOMAIN] = data + Creation is deferred to the first caller rather than done at setup, to + avoid building DlnaDmsData and its dependencies until a device is + actually connected to. This module is imported to run the config flow + for any DMS device discovered on the network, including ignored ones. + """ + if (data := hass.data.get(DOMAIN_DATA)) is not None: + return data + + data = hass.data[DOMAIN_DATA] = DlnaDmsData(hass) return data From f5f2848d94fb3df88c635ebbc6651f8fd1aa70f6 Mon Sep 17 00:00:00 2001 From: David Wu <133224895+David-Wu1119@users.noreply.github.com> Date: Mon, 7 Sep 2026 01:04:47 -0700 Subject: [PATCH 18/20] Type the dlna_dmr domain data with a HassKey (#181101) --- homeassistant/components/dlna_dmr/const.py | 10 +++++++++- homeassistant/components/dlna_dmr/data.py | 20 ++++++++++++-------- 2 files changed, 21 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/dlna_dmr/const.py b/homeassistant/components/dlna_dmr/const.py index cb308faea15fd0..14fd7b9ce96dcb 100644 --- a/homeassistant/components/dlna_dmr/const.py +++ b/homeassistant/components/dlna_dmr/const.py @@ -2,16 +2,24 @@ from collections.abc import Mapping import logging -from typing import Final +from typing import TYPE_CHECKING, Final from async_upnp_client.profiles.dlna import PlayMode as _PlayMode from homeassistant.components.media_player import MediaType, RepeatMode +from homeassistant.util.hass_dict import HassKey + +if TYPE_CHECKING: + from .data import DlnaDmrData LOGGER = logging.getLogger(__package__) DOMAIN: Final = "dlna_dmr" +# One DlnaDmrData owns the shared UPnP requester and event notifiers used by +# every config entry, so it is not per-entry state. +DOMAIN_DATA: HassKey[DlnaDmrData] = HassKey(DOMAIN) + CONF_LISTEN_PORT: Final = "listen_port" CONF_CALLBACK_URL_OVERRIDE: Final = "callback_url_override" CONF_POLL_AVAILABILITY: Final = "poll_availability" diff --git a/homeassistant/components/dlna_dmr/data.py b/homeassistant/components/dlna_dmr/data.py index 7b5a36fffe3f49..fcc3c17cfe7fb2 100644 --- a/homeassistant/components/dlna_dmr/data.py +++ b/homeassistant/components/dlna_dmr/data.py @@ -1,9 +1,8 @@ """Data used by this integration.""" -# pylint: disable=home-assistant-use-runtime-data # Uses legacy hass.data[DOMAIN] pattern import asyncio from collections import defaultdict -from typing import NamedTuple, cast +from typing import NamedTuple from async_upnp_client.aiohttp import AiohttpNotifyServer, AiohttpSessionRequester from async_upnp_client.client import UpnpRequester @@ -14,7 +13,7 @@ from homeassistant.core import CALLBACK_TYPE, Event, HomeAssistant from homeassistant.helpers import aiohttp_client -from .const import DOMAIN, LOGGER +from .const import DOMAIN_DATA, LOGGER class EventListenAddr(NamedTuple): @@ -117,10 +116,15 @@ async def async_release_event_notifier(self, listen_addr: EventListenAddr) -> No def get_domain_data(hass: HomeAssistant) -> DlnaDmrData: - """Obtain this integration's domain data, creating it if needed.""" - if DOMAIN in hass.data: - return cast(DlnaDmrData, hass.data[DOMAIN]) + """Obtain this integration's domain data, creating it if needed. - data = DlnaDmrData(hass) - hass.data[DOMAIN] = data + Creation is deferred to the first caller rather than done at setup, to + avoid building DlnaDmrData and its dependencies until a device is + actually connected to. This module is imported to run the config flow + for any DMR device discovered on the network, including ignored ones. + """ + if (data := hass.data.get(DOMAIN_DATA)) is not None: + return data + + data = hass.data[DOMAIN_DATA] = DlnaDmrData(hass) return data From ec82dee53dbaaa2a6e263542484bd3cc44700b3d Mon Sep 17 00:00:00 2001 From: Brett Adams Date: Mon, 7 Sep 2026 18:14:16 +1000 Subject: [PATCH 19/20] Use PressureConverter for Teslemetry TPMS streaming conversion (#181508) --- homeassistant/components/teslemetry/sensor.py | 36 ++++++++++++--- tests/components/teslemetry/test_sensor.py | 46 +++++++++++++++---- 2 files changed, 65 insertions(+), 17 deletions(-) diff --git a/homeassistant/components/teslemetry/sensor.py b/homeassistant/components/teslemetry/sensor.py index 3feffe7139eb16..5a9c88adb51853 100644 --- a/homeassistant/components/teslemetry/sensor.py +++ b/homeassistant/components/teslemetry/sensor.py @@ -34,6 +34,7 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType from homeassistant.util import dt as dt_util +from homeassistant.util.unit_conversion import PressureConverter from homeassistant.util.variance import ignore_variance from . import TeslemetryConfigEntry @@ -50,9 +51,6 @@ PARALLEL_UPDATES = 0 -# Teslemetry streams TPMS pressure in atmospheres; entities are declared in bar. -ATM_TO_BAR = 1.01325 - # Tesla only reports the self-driving/mileage-since-reset fields (258-259) on HW4 # vehicles, identified by this driver-assist capability in the vehicle config. DRIVER_ASSIST_HW4 = "TeslaAP4" @@ -403,7 +401,13 @@ class TeslemetryVehicleSensorEntityDescription(SensorEntityDescription): key="vehicle_state_tpms_pressure_fl", polling=True, streaming_listener=lambda vehicle, callback: vehicle.listen_TpmsPressureFl( - lambda x: callback(None) if x is None else callback(x * ATM_TO_BAR) + lambda x: ( + callback(None) + if x is None + else callback( + PressureConverter.convert(x, UnitOfPressure.ATM, UnitOfPressure.BAR) + ) + ) ), state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfPressure.BAR, @@ -417,7 +421,13 @@ class TeslemetryVehicleSensorEntityDescription(SensorEntityDescription): key="vehicle_state_tpms_pressure_fr", polling=True, streaming_listener=lambda vehicle, callback: vehicle.listen_TpmsPressureFr( - lambda x: callback(None) if x is None else callback(x * ATM_TO_BAR) + lambda x: ( + callback(None) + if x is None + else callback( + PressureConverter.convert(x, UnitOfPressure.ATM, UnitOfPressure.BAR) + ) + ) ), state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfPressure.BAR, @@ -431,7 +441,13 @@ class TeslemetryVehicleSensorEntityDescription(SensorEntityDescription): key="vehicle_state_tpms_pressure_rl", polling=True, streaming_listener=lambda vehicle, callback: vehicle.listen_TpmsPressureRl( - lambda x: callback(None) if x is None else callback(x * ATM_TO_BAR) + lambda x: ( + callback(None) + if x is None + else callback( + PressureConverter.convert(x, UnitOfPressure.ATM, UnitOfPressure.BAR) + ) + ) ), state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfPressure.BAR, @@ -445,7 +461,13 @@ class TeslemetryVehicleSensorEntityDescription(SensorEntityDescription): key="vehicle_state_tpms_pressure_rr", polling=True, streaming_listener=lambda vehicle, callback: vehicle.listen_TpmsPressureRr( - lambda x: callback(None) if x is None else callback(x * ATM_TO_BAR) + lambda x: ( + callback(None) + if x is None + else callback( + PressureConverter.convert(x, UnitOfPressure.ATM, UnitOfPressure.BAR) + ) + ) ), state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfPressure.BAR, diff --git a/tests/components/teslemetry/test_sensor.py b/tests/components/teslemetry/test_sensor.py index bb2c23fefe33fd..3d5ffeda3628b2 100644 --- a/tests/components/teslemetry/test_sensor.py +++ b/tests/components/teslemetry/test_sensor.py @@ -16,11 +16,9 @@ STATE_UNKNOWN, EntityCategory, Platform, - UnitOfPressure, ) from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -from homeassistant.util.unit_conversion import PressureConverter from . import assert_entities, assert_entities_alt, setup_platform from .const import ( @@ -326,29 +324,25 @@ async def test_hw4_mileage_sensors_gating( Signal.TPMS_PRESSURE_FL, "sensor.test_tire_pressure_front_left", 2.7, - # 2.7 atm independently hand-converted to bar (2.7 * 1.01325 = 2.735775) - PressureConverter.convert(2.735775, UnitOfPressure.BAR, UnitOfPressure.PSI), + 39.679063381059, ), ( Signal.TPMS_PRESSURE_FR, "sensor.test_tire_pressure_front_right", 2.7, - # 2.7 atm independently hand-converted to bar (2.7 * 1.01325 = 2.735775) - PressureConverter.convert(2.735775, UnitOfPressure.BAR, UnitOfPressure.PSI), + 39.679063381059, ), ( Signal.TPMS_PRESSURE_RL, "sensor.test_tire_pressure_rear_left", 2.7, - # 2.7 atm independently hand-converted to bar (2.7 * 1.01325 = 2.735775) - PressureConverter.convert(2.735775, UnitOfPressure.BAR, UnitOfPressure.PSI), + 39.679063381059, ), ( Signal.TPMS_PRESSURE_RR, "sensor.test_tire_pressure_rear_right", 2.7, - # 2.7 atm independently hand-converted to bar (2.7 * 1.01325 = 2.735775) - PressureConverter.convert(2.735775, UnitOfPressure.BAR, UnitOfPressure.PSI), + 39.679063381059, ), ( Signal.ISOLATION_RESISTANCE, @@ -386,6 +380,38 @@ async def test_sensors_streaming_unit_conversion( assert float(state.state) == pytest.approx(expected_state) +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_sensors_streaming_tpms_none_clears_state( + hass: HomeAssistant, + mock_vehicle_data: AsyncMock, + mock_add_listener: AsyncMock, +) -> None: + """A None streamed TPMS pressure must clear the entity, not pass through the converter.""" + entity_id = "sensor.test_tire_pressure_front_left" + await setup_platform(hass, [Platform.SENSOR]) + vin = VEHICLE_DATA_ALT["response"]["vin"] + + mock_add_listener.send( + { + "vin": vin, + "data": {Signal.TPMS_PRESSURE_FL: 2.7}, + "createdAt": "2024-10-04T10:45:17.537Z", + } + ) + await hass.async_block_till_done() + assert hass.states.get(entity_id).state != STATE_UNKNOWN + + mock_add_listener.send( + { + "vin": vin, + "data": {Signal.TPMS_PRESSURE_FL: None}, + "createdAt": "2024-10-04T10:45:18.537Z", + } + ) + await hass.async_block_till_done() + assert hass.states.get(entity_id).state == STATE_UNKNOWN + + @pytest.mark.parametrize( ("key", "signal", "raw_value", "state"), [ From 2da171b84e5e9b9319f650401e29420f8279e36b Mon Sep 17 00:00:00 2001 From: Thomas Munzer Date: Mon, 7 Sep 2026 10:35:25 +0200 Subject: [PATCH 20/20] Add switch platform to Tuya ZNJDQ (circuit breaker) (#181517) Co-authored-by: Thomas Munzer --- homeassistant/components/tuya/const.py | 2 + homeassistant/components/tuya/switch.py | 13 +++ .../components/tuya/snapshots/test_init.ambr | 2 +- .../tuya/snapshots/test_switch.ambr | 101 ++++++++++++++++++ 4 files changed, 117 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/tuya/const.py b/homeassistant/components/tuya/const.py index 49a34db9064e3e..4a4a2505f8c660 100644 --- a/homeassistant/components/tuya/const.py +++ b/homeassistant/components/tuya/const.py @@ -574,6 +574,8 @@ class DeviceCategory(StrEnum): """Tank Level Sensor (undocumented)""" ZNNBQ = "znnbq" """VESKA-micro inverter (undocumented)""" + ZNJDQ = "znjdq" + """Circuit breaker (undocumented)""" ZWJCY = "zwjcy" """Soil sensor - plant monitor (undocumented)""" ZNJXS = "znjxs" diff --git a/homeassistant/components/tuya/switch.py b/homeassistant/components/tuya/switch.py index ac5654416bb6d3..4c6e0ec3b4fe94 100644 --- a/homeassistant/components/tuya/switch.py +++ b/homeassistant/components/tuya/switch.py @@ -894,6 +894,19 @@ translation_key="switch", ), ), + DeviceCategory.ZNJDQ: ( + SwitchEntityDescription( + key=DPCode.SWITCH_1, + translation_key="indexed_switch", + translation_placeholders={"index": "1"}, + ), + SwitchEntityDescription( + key=DPCode.CHILD_LOCK, + translation_key="child_lock", + icon="mdi:account-lock", + entity_category=EntityCategory.CONFIG, + ), + ), DeviceCategory.ZNJXS: ( SwitchEntityDescription( key=DPCode.SWITCH, diff --git a/tests/components/tuya/snapshots/test_init.ambr b/tests/components/tuya/snapshots/test_init.ambr index 775c1434e1264b..94ee2b2996b30a 100644 --- a/tests/components/tuya/snapshots/test_init.ambr +++ b/tests/components/tuya/snapshots/test_init.ambr @@ -5090,7 +5090,7 @@ 'labels': set({ }), 'manufacturer': 'Tuya', - 'model': '63A Smart Circuit Breaker (unsupported)', + 'model': '63A Smart Circuit Breaker', 'model_id': 'au6dqazvkxqnpaak', 'name': 'Smart Circuit Breaker', 'name_by_user': None, diff --git a/tests/components/tuya/snapshots/test_switch.ambr b/tests/components/tuya/snapshots/test_switch.ambr index dd4813264bddc0..c34d82e1886ee0 100644 --- a/tests/components/tuya/snapshots/test_switch.ambr +++ b/tests/components/tuya/snapshots/test_switch.ambr @@ -10130,6 +10130,107 @@ 'state': 'off', }) # --- +# name: test_platform_setup_and_discovery[switch.smart_circuit_breaker_child_lock-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + '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_circuit_breaker_child_lock', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Child lock', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:account-lock', + 'original_name': 'Child lock', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'child_lock', + 'unique_id': 'tuya.kaapnqxkvzaqd6uaqdjnzchild_lock', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[switch.smart_circuit_breaker_child_lock-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'Smart Circuit Breaker Child lock', + : 'mdi:account-lock', + }), + 'context': , + 'entity_id': 'switch.smart_circuit_breaker_child_lock', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_platform_setup_and_discovery[switch.smart_circuit_breaker_switch_1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + '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_circuit_breaker_switch_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Switch 1', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Switch 1', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'indexed_switch', + 'unique_id': 'tuya.kaapnqxkvzaqd6uaqdjnzswitch_1', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[switch.smart_circuit_breaker_switch_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'Smart Circuit Breaker Switch 1', + }), + 'context': , + 'entity_id': 'switch.smart_circuit_breaker_switch_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- # name: test_platform_setup_and_discovery[switch.smart_kettle_start-entry] EntityRegistryEntrySnapshot({ 'aliases': list([