From 37eebc36710924ae671fc48f2e0313aa184e8727 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Sat, 22 Aug 2026 06:31:25 +0200 Subject: [PATCH 01/18] Restore the recommended LLM API fallback for LiteLLM (#179768) Co-authored-by: Claude --- homeassistant/components/litellm/config_flow.py | 7 +++++-- tests/components/litellm/test_config_flow.py | 3 ++- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/litellm/config_flow.py b/homeassistant/components/litellm/config_flow.py index 2ffecc07e3923..e22fbcce329da 100644 --- a/homeassistant/components/litellm/config_flow.py +++ b/homeassistant/components/litellm/config_flow.py @@ -180,7 +180,7 @@ async def async_step_init( return self.async_abort(reason="entry_not_loaded") if user_input is not None: - if not user_input.get(CONF_LLM_HASS_API): + if user_input.get(CONF_LLM_HASS_API) is None: user_input.pop(CONF_LLM_HASS_API, None) if self._is_new: return self.async_create_entry( @@ -241,7 +241,10 @@ async def async_step_init( ): TemplateSelector(), vol.Optional( CONF_LLM_HASS_API, - default=self.options.get(CONF_LLM_HASS_API, []), + default=self.options.get( + CONF_LLM_HASS_API, + RECOMMENDED_CONVERSATION_OPTIONS[CONF_LLM_HASS_API], + ), ): SelectSelector( SelectSelectorConfig(options=hass_apis, multiple=True) ), diff --git a/tests/components/litellm/test_config_flow.py b/tests/components/litellm/test_config_flow.py index 74dd58cfa5e28..57edc21d4382a 100644 --- a/tests/components/litellm/test_config_flow.py +++ b/tests/components/litellm/test_config_flow.py @@ -242,6 +242,7 @@ async def test_create_conversation_agent_no_control( assert result["data"] == { CONF_MODEL: "gpt-3.5-turbo", CONF_PROMPT: "you are an assistant", + CONF_LLM_HASS_API: [], } @@ -360,7 +361,7 @@ async def test_reconfigure_conversation_agent_disable_llm_api( ) assert result["type"] is FlowResultType.ABORT assert result["reason"] == "reconfigure_successful" - assert CONF_LLM_HASS_API not in mock_config_entry.subentries[subentry_id].data + assert mock_config_entry.subentries[subentry_id].data[CONF_LLM_HASS_API] == [] result = await mock_config_entry.start_subentry_reconfigure_flow(hass, subentry_id) schema = result["data_schema"].schema From 49259e342627457e64d2733e7753703d090ac67d Mon Sep 17 00:00:00 2001 From: Greg Haines Date: Fri, 21 Aug 2026 23:17:52 -0600 Subject: [PATCH 02/18] Localize exception messages for CentriConnect (#179752) --- .../components/centriconnect/coordinator.py | 20 +++++++++++--- .../centriconnect/quality_scale.yaml | 2 +- .../components/centriconnect/strings.json | 11 ++++++++ tests/components/centriconnect/test_init.py | 27 +++++++++++++++++++ 4 files changed, 56 insertions(+), 4 deletions(-) create mode 100644 tests/components/centriconnect/test_init.py diff --git a/homeassistant/components/centriconnect/coordinator.py b/homeassistant/components/centriconnect/coordinator.py index 1733a18fa958c..a77a8f6d8184d 100644 --- a/homeassistant/components/centriconnect/coordinator.py +++ b/homeassistant/components/centriconnect/coordinator.py @@ -71,7 +71,9 @@ async def _async_setup(self) -> None: try: tank_data = await self.api_client.async_get_tank_data() except CentriConnectError as err: - raise UpdateFailed("Could not fetch device info") from err + raise UpdateFailed( + translation_domain=DOMAIN, translation_key="entry_setup_failed" + ) from err self.device_info = CentriConnectDeviceInfo( device_id=tank_data.device_id, device_name=tank_data.device_name, @@ -87,7 +89,19 @@ async def _async_update_data(self) -> Tank: try: state = await self.api_client.async_get_tank_data() except CentriConnectConnectionError as err: - raise UpdateFailed(f"Error communicating with device: {err}") from err + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key="communication_error", + translation_placeholders={ + "error": repr(err), + }, + ) from err except CentriConnectError as err: - raise UpdateFailed(f"Unexpected response: {err}") from err + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key="unexpected_response", + translation_placeholders={ + "error": repr(err), + }, + ) from err return state diff --git a/homeassistant/components/centriconnect/quality_scale.yaml b/homeassistant/components/centriconnect/quality_scale.yaml index 8fed92abc5b9a..d0bc918ebcd6b 100644 --- a/homeassistant/components/centriconnect/quality_scale.yaml +++ b/homeassistant/components/centriconnect/quality_scale.yaml @@ -68,7 +68,7 @@ rules: entity-device-class: done entity-disabled-by-default: done entity-translations: done - exception-translations: todo + exception-translations: done icon-translations: done reconfiguration-flow: todo repair-issues: diff --git a/homeassistant/components/centriconnect/strings.json b/homeassistant/components/centriconnect/strings.json index fffe7a037a52a..4f9c6cc8943ee 100644 --- a/homeassistant/components/centriconnect/strings.json +++ b/homeassistant/components/centriconnect/strings.json @@ -65,5 +65,16 @@ "name": "Tank size" } } + }, + "exceptions": { + "communication_error": { + "message": "Error communicating with device: {error}" + }, + "entry_setup_failed": { + "message": "Could not fetch device info" + }, + "unexpected_response": { + "message": "Unexpected response: {error}" + } } } diff --git a/tests/components/centriconnect/test_init.py b/tests/components/centriconnect/test_init.py new file mode 100644 index 0000000000000..02b413bd967b4 --- /dev/null +++ b/tests/components/centriconnect/test_init.py @@ -0,0 +1,27 @@ +"""Tests for the CentriConnect/MyPropane configuration initialization.""" + +from unittest.mock import AsyncMock + +from aiocentriconnect.exceptions import CentriConnectConnectionError + +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant + +from . import setup_integration + +from tests.common import MockConfigEntry + + +async def test_config_entry_not_ready( + hass: HomeAssistant, + mock_centriconnect_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test config entry not ready.""" + mock_centriconnect_client.async_get_tank_data.side_effect = ( + CentriConnectConnectionError + ) + await setup_integration(hass, mock_config_entry) + + assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY + mock_centriconnect_client.async_get_tank_data.side_effect = None From 839bb52aba423ba5ee9aa98d2c59fbf4b83e5bf0 Mon Sep 17 00:00:00 2001 From: Christophe Gagnier Date: Sat, 22 Aug 2026 01:54:19 -0400 Subject: [PATCH 03/18] Add diagnostics platform to Hot Spring (#179778) Co-authored-by: Moustachauve <2206577+Moustachauve@users.noreply.github.com> --- .../components/hotspring/diagnostics.py | 58 +++ .../components/hotspring/quality_scale.yaml | 2 +- tests/components/hotspring/conftest.py | 97 +++++- .../hotspring/snapshots/test_diagnostics.ambr | 329 ++++++++++++++++++ .../components/hotspring/test_diagnostics.py | 42 +++ tests/components/hotspring/test_init.py | 3 +- 6 files changed, 523 insertions(+), 8 deletions(-) create mode 100644 homeassistant/components/hotspring/diagnostics.py create mode 100644 tests/components/hotspring/snapshots/test_diagnostics.ambr create mode 100644 tests/components/hotspring/test_diagnostics.py diff --git a/homeassistant/components/hotspring/diagnostics.py b/homeassistant/components/hotspring/diagnostics.py new file mode 100644 index 0000000000000..889fe04fded9f --- /dev/null +++ b/homeassistant/components/hotspring/diagnostics.py @@ -0,0 +1,58 @@ +"""Diagnostics support for Hot Spring.""" + +from dataclasses import asdict +import re +from typing import Any + +from homeassistant.components.diagnostics import REDACTED, async_redact_data +from homeassistant.const import CONF_HOST +from homeassistant.core import HomeAssistant + +from .coordinator import HotSpringConfigEntry + +TO_REDACT = { + CONF_HOST, +} + + +def _redact_mac(value: str, patterns: list[str]) -> str: + """Redact MAC address patterns from a string.""" + for pattern in patterns: + value = re.sub(re.escape(pattern), REDACTED, value, flags=re.IGNORECASE) + return value + + +async def async_get_config_entry_diagnostics( + hass: HomeAssistant, entry: HotSpringConfigEntry +) -> dict[str, Any]: + """Return diagnostics for a config entry.""" + coordinator = entry.runtime_data + spa = coordinator.data + + info = asdict(spa.info) + if mac_address := spa.info.mac_address: + clean_mac = mac_address.replace(":", "") + patterns = [mac_address, clean_mac, clean_mac[-6:]] + info["root_topic"] = _redact_mac(info["root_topic"], patterns) + info["hostname"] = _redact_mac(info["hostname"], patterns) + + return { + "entry": async_redact_data(entry.data, TO_REDACT), + "data": { + "info": info, + "heater": asdict(spa.heater), + "jets": [asdict(jet) for jet in spa.jets], + "blower": asdict(spa.blower), + "light_zones": [asdict(zone) for zone in spa.light_zones], + "logo_light": asdict(spa.logo_light), + "clean_cycle": asdict(spa.clean_cycle), + "spa_lock": asdict(spa.spa_lock), + "water_care": asdict(spa.water_care), + "freshwater_iq": asdict(spa.freshwater_iq), + "energy_savings": [asdict(schedule) for schedule in spa.energy_savings], + "versions": asdict(spa.versions), + "connection_status": asdict(spa.connection_status), + "diagnostics": asdict(spa.diagnostics), + "test_metrics": asdict(spa.test_metrics), + }, + } diff --git a/homeassistant/components/hotspring/quality_scale.yaml b/homeassistant/components/hotspring/quality_scale.yaml index 171e6cfae2f13..fba94802633de 100644 --- a/homeassistant/components/hotspring/quality_scale.yaml +++ b/homeassistant/components/hotspring/quality_scale.yaml @@ -49,7 +49,7 @@ rules: # Gold devices: done - diagnostics: todo + diagnostics: done discovery-update-info: done discovery: done docs-data-update: done diff --git a/tests/components/hotspring/conftest.py b/tests/components/hotspring/conftest.py index a9f2d96c96aca..ccfb454f6a7d3 100644 --- a/tests/components/hotspring/conftest.py +++ b/tests/components/hotspring/conftest.py @@ -3,7 +3,32 @@ from collections.abc import Generator from unittest.mock import AsyncMock, MagicMock, patch -from hotspring import Heater, Spa, SpaBrand, SpaInfo, Versions, WaterCare +from hotspring import ( + Blower, + BrightnessLevel, + CleanCycle, + ConnectionStatus, + Diagnostics, + EnergySaving, + FreshWaterIQ, + Heater, + HeatingMode, + Jet, + JetSpeed, + LightColor, + LightWheelMode, + LightZone, + LogoLight, + Spa, + SpaBrand, + SpaFailureState, + SpaInfo, + SpaLock, + TemperatureUnit, + Versions, + WaterCare, +) +from hotspring.models import SpaTestData import pytest from homeassistant.components.hotspring.const import DOMAIN @@ -61,11 +86,17 @@ def device_fixture() -> Spa: dosing="", logolight="", ) - heater = MagicMock(spec=Heater) - heater.current_temperature = 102.0 - heater.set_temperature = 104.0 - heater.is_on = True - spa.heater = heater + spa.heater = Heater( + is_on=True, + heater_lock=False, + heatpump_installed=False, + heating_mode=HeatingMode.HEAT_SAVER, + heater_current=5.0, + heater_on_seconds=3600, + set_temperature=104.0, + current_temperature=102.0, + temperature_unit=TemperatureUnit.FAHRENHEIT, + ) spa.water_care = WaterCare( cartridge_installed=True, ten_day_timer=0, @@ -76,6 +107,60 @@ def device_fixture() -> Spa: boost_active=False, salt_value=12, ) + spa.jets = [ + Jet(jet_id=1, speed=JetSpeed.OFF, is_enabled=True, on_seconds=0), + Jet(jet_id=2, speed=JetSpeed.OFF, is_enabled=True, on_seconds=0), + ] + spa.blower = Blower(is_enabled=False, is_on=False) + spa.light_zones = [ + LightZone( + zone_id=1, + is_enabled=True, + is_on=False, + color=LightColor.OFF, + light_wheel=LightWheelMode.OFF, + intensity=0, + loop_speed=0, + ), + ] + spa.logo_light = LogoLight(brightness=BrightnessLevel.LEVEL_1) + spa.clean_cycle = CleanCycle(is_enabled=False, vanishing_act=False) + spa.spa_lock = SpaLock(is_locked=False) + spa.freshwater_iq = FreshWaterIQ( + conductivity=0, + orp=0, + chlorine=0.0, + ph=7.2, + sensor_life_percentage=100.0, + installed=False, + ) + spa.energy_savings = [ + EnergySaving(schedule_id=1, mode=0, start_hour=0, start_minute=0, duration=0), + ] + spa.connection_status = ConnectionStatus(spa_connected=True) + spa.diagnostics = Diagnostics( + spa_failure_state=SpaFailureState.OK, + heater_error="0", + power_frequency="60", + pressure_switch_status="0", + l1_n_volts=120.0, + l2_n_volts=120.0, + heater_volts=240.0, + jet3_volts=0.0, + jet1_jet2_blower_power="0", + small_loads_power="0", + heater_power="0", + jet3_power="0", + ) + spa.test_metrics = SpaTestData( + heater_test_status="off", + temp_offset=0.0, + vsense_cal=0.0, + jet1_jet2_blower_current=0.0, + small_loads_current=0.0, + heater_current=0.0, + jet3_current=0.0, + ) return spa diff --git a/tests/components/hotspring/snapshots/test_diagnostics.ambr b/tests/components/hotspring/snapshots/test_diagnostics.ambr new file mode 100644 index 0000000000000..7cbf41189e487 --- /dev/null +++ b/tests/components/hotspring/snapshots/test_diagnostics.ambr @@ -0,0 +1,329 @@ +# serializer version: 1 +# name: test_diagnostics + dict({ + 'data': dict({ + 'blower': dict({ + 'is_enabled': False, + 'is_on': False, + }), + 'clean_cycle': dict({ + 'is_enabled': False, + 'vanishing_act': False, + }), + 'connection_status': dict({ + 'spa_connected': True, + }), + 'diagnostics': dict({ + 'heater_error': '0', + 'heater_power': '0', + 'heater_volts': 240.0, + 'jet1_jet2_blower_power': '0', + 'jet3_power': '0', + 'jet3_volts': 0.0, + 'l1_n_volts': 120.0, + 'l2_n_volts': 120.0, + 'power_frequency': '60', + 'pressure_switch_status': '0', + 'small_loads_power': '0', + 'spa_failure_state': dict({ + '__type': "", + 'repr': "", + }), + }), + 'energy_savings': list([ + dict({ + 'duration': 0, + 'mode': 0, + 'schedule_id': 1, + 'start_hour': 0, + 'start_minute': 0, + }), + ]), + 'freshwater_iq': dict({ + 'chlorine': 0.0, + 'conductivity': 0, + 'installed': False, + 'orp': 0, + 'ph': 7.2, + 'sensor_life_percentage': 100.0, + }), + 'heater': dict({ + 'current_temperature': 102.0, + 'heater_current': 5.0, + 'heater_lock': False, + 'heater_on_seconds': 3600, + 'heating_mode': dict({ + '__type': "", + 'repr': "", + }), + 'heatpump_installed': False, + 'is_on': True, + 'set_temperature': 104.0, + 'temperature_unit': dict({ + '__type': "", + 'repr': "", + }), + }), + 'info': dict({ + 'brand': dict({ + '__type': "", + 'repr': "", + }), + 'brand_id': '1', + 'brand_name': 'Hot Spring', + 'collection': 'Highlife', + 'collection_id': '1', + 'hostname': 'ConnectedSpa_**REDACTED**', + 'model_id': '1', + 'model_name': 'Relay', + 'root_topic': 'mySpa**REDACTED**', + 'sna_ready': True, + 'volume': 335, + }), + 'jets': list([ + dict({ + 'is_enabled': True, + 'jet_id': 1, + 'on_seconds': 0, + 'speed': dict({ + '__type': "", + 'repr': "", + }), + }), + dict({ + 'is_enabled': True, + 'jet_id': 2, + 'on_seconds': 0, + 'speed': dict({ + '__type': "", + 'repr': "", + }), + }), + ]), + 'light_zones': list([ + dict({ + 'color': dict({ + '__type': "", + 'repr': "", + }), + 'intensity': 0, + 'is_enabled': True, + 'is_on': False, + 'light_wheel': dict({ + '__type': "", + 'repr': "", + }), + 'loop_speed': 0, + 'zone_id': 1, + }), + ]), + 'logo_light': dict({ + 'brightness': dict({ + '__type': "", + 'repr': "", + }), + }), + 'spa_lock': dict({ + 'is_locked': False, + }), + 'test_metrics': dict({ + 'heater_current': 0.0, + 'heater_test_status': 'off', + 'jet1_jet2_blower_current': 0.0, + 'jet3_current': 0.0, + 'small_loads_current': 0.0, + 'temp_offset': 0.0, + 'vsense_cal': 0.0, + }), + 'versions': dict({ + 'amp': '', + 'btxr': '', + 'control_box': '3.0.0', + 'control_panel': '2.0.0', + 'cool_zone': '', + 'dosing': '', + 'fwiq': '', + 'fwss': '1.0.0', + 'logolight': '', + 'wifi_dongle': '1.0.0', + }), + 'water_care': dict({ + 'ace_mode': 'inactive', + 'boost_active': False, + 'cartridge_installed': True, + 'level': 2, + 'one_twenty_day_timer': 117, + 'salt_value': 12, + 'system_enabled': True, + 'ten_day_timer': 0, + }), + }), + 'entry': dict({ + 'host': '**REDACTED**', + }), + }) +# --- +# name: test_diagnostics_custom_topic + dict({ + 'data': dict({ + 'blower': dict({ + 'is_enabled': False, + 'is_on': False, + }), + 'clean_cycle': dict({ + 'is_enabled': False, + 'vanishing_act': False, + }), + 'connection_status': dict({ + 'spa_connected': True, + }), + 'diagnostics': dict({ + 'heater_error': '0', + 'heater_power': '0', + 'heater_volts': 240.0, + 'jet1_jet2_blower_power': '0', + 'jet3_power': '0', + 'jet3_volts': 0.0, + 'l1_n_volts': 120.0, + 'l2_n_volts': 120.0, + 'power_frequency': '60', + 'pressure_switch_status': '0', + 'small_loads_power': '0', + 'spa_failure_state': dict({ + '__type': "", + 'repr': "", + }), + }), + 'energy_savings': list([ + dict({ + 'duration': 0, + 'mode': 0, + 'schedule_id': 1, + 'start_hour': 0, + 'start_minute': 0, + }), + ]), + 'freshwater_iq': dict({ + 'chlorine': 0.0, + 'conductivity': 0, + 'installed': False, + 'orp': 0, + 'ph': 7.2, + 'sensor_life_percentage': 100.0, + }), + 'heater': dict({ + 'current_temperature': 102.0, + 'heater_current': 5.0, + 'heater_lock': False, + 'heater_on_seconds': 3600, + 'heating_mode': dict({ + '__type': "", + 'repr': "", + }), + 'heatpump_installed': False, + 'is_on': True, + 'set_temperature': 104.0, + 'temperature_unit': dict({ + '__type': "", + 'repr': "", + }), + }), + 'info': dict({ + 'brand': dict({ + '__type': "", + 'repr': "", + }), + 'brand_id': '1', + 'brand_name': 'Hot Spring', + 'collection': 'Highlife', + 'collection_id': '1', + 'hostname': 'customHost', + 'model_id': '1', + 'model_name': 'Relay', + 'root_topic': 'customTopic', + 'sna_ready': True, + 'volume': 335, + }), + 'jets': list([ + dict({ + 'is_enabled': True, + 'jet_id': 1, + 'on_seconds': 0, + 'speed': dict({ + '__type': "", + 'repr': "", + }), + }), + dict({ + 'is_enabled': True, + 'jet_id': 2, + 'on_seconds': 0, + 'speed': dict({ + '__type': "", + 'repr': "", + }), + }), + ]), + 'light_zones': list([ + dict({ + 'color': dict({ + '__type': "", + 'repr': "", + }), + 'intensity': 0, + 'is_enabled': True, + 'is_on': False, + 'light_wheel': dict({ + '__type': "", + 'repr': "", + }), + 'loop_speed': 0, + 'zone_id': 1, + }), + ]), + 'logo_light': dict({ + 'brightness': dict({ + '__type': "", + 'repr': "", + }), + }), + 'spa_lock': dict({ + 'is_locked': False, + }), + 'test_metrics': dict({ + 'heater_current': 0.0, + 'heater_test_status': 'off', + 'jet1_jet2_blower_current': 0.0, + 'jet3_current': 0.0, + 'small_loads_current': 0.0, + 'temp_offset': 0.0, + 'vsense_cal': 0.0, + }), + 'versions': dict({ + 'amp': '', + 'btxr': '', + 'control_box': '3.0.0', + 'control_panel': '2.0.0', + 'cool_zone': '', + 'dosing': '', + 'fwiq': '', + 'fwss': '1.0.0', + 'logolight': '', + 'wifi_dongle': '1.0.0', + }), + 'water_care': dict({ + 'ace_mode': 'inactive', + 'boost_active': False, + 'cartridge_installed': True, + 'level': 2, + 'one_twenty_day_timer': 117, + 'salt_value': 12, + 'system_enabled': True, + 'ten_day_timer': 0, + }), + }), + 'entry': dict({ + 'host': '**REDACTED**', + }), + }) +# --- diff --git a/tests/components/hotspring/test_diagnostics.py b/tests/components/hotspring/test_diagnostics.py new file mode 100644 index 0000000000000..ea2742869a83a --- /dev/null +++ b/tests/components/hotspring/test_diagnostics.py @@ -0,0 +1,42 @@ +"""Tests for the diagnostics data provided by the Hot Spring integration.""" + +from syrupy.assertion import SnapshotAssertion + +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry +from tests.components.diagnostics import get_diagnostics_for_config_entry +from tests.typing import ClientSessionGenerator + + +async def test_diagnostics( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + init_integration: MockConfigEntry, + snapshot: SnapshotAssertion, +) -> None: + """Test diagnostics.""" + assert ( + await get_diagnostics_for_config_entry(hass, hass_client, init_integration) + == snapshot + ) + + +async def test_diagnostics_custom_topic( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + init_integration: MockConfigEntry, + snapshot: SnapshotAssertion, +) -> None: + """Test diagnostics to ensure root_topic without MAC address is not redacted. + + This preserves diagnosing capabilities in case a spa model acts differently than expected. + """ + coordinator = init_integration.runtime_data + coordinator.data.info.root_topic = "customTopic" + coordinator.data.info.hostname = "customHost" + + assert ( + await get_diagnostics_for_config_entry(hass, hass_client, init_integration) + == snapshot + ) diff --git a/tests/components/hotspring/test_init.py b/tests/components/hotspring/test_init.py index e87c42851cf71..99230fc06a333 100644 --- a/tests/components/hotspring/test_init.py +++ b/tests/components/hotspring/test_init.py @@ -1,5 +1,6 @@ """Tests for the Hot Spring integration.""" +from typing import cast from unittest.mock import MagicMock from hotspring import HotSpringConnectionError, HotSpringError, Spa @@ -22,7 +23,7 @@ async def test_async_setup_entry( assert await hass.config_entries.async_unload(init_integration.entry_id) await hass.async_block_till_done() - assert init_integration.state is ConfigEntryState.NOT_LOADED + assert cast(ConfigEntryState, init_integration.state) is ConfigEntryState.NOT_LOADED async def test_device_info( From 8e05f4b286ee770e04b12cbb10a30ea6721b513a Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Sat, 22 Aug 2026 08:26:51 +0200 Subject: [PATCH 04/18] Name the ekey bionyx webhook deletion poll interval (#179788) Co-authored-by: Claude --- homeassistant/components/ekeybionyx/config_flow.py | 4 +++- tests/components/ekeybionyx/test_config_flow.py | 1 + 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/ekeybionyx/config_flow.py b/homeassistant/components/ekeybionyx/config_flow.py index b77d4cc4b98eb..41dd47eb1bc82 100644 --- a/homeassistant/components/ekeybionyx/config_flow.py +++ b/homeassistant/components/ekeybionyx/config_flow.py @@ -29,6 +29,8 @@ # does not end with space or dot VALID_NAME_PATTERN = re.compile(r"^(?![\d\s])[\w\d \.]*[\w\d]$") +DELETION_POLL_INTERVAL = 5 + class ConfigFlowEkeyApi(ekey_bionyxpy.AbstractAuth): """Authentication implementation used during config flow, without refresh. @@ -276,4 +278,4 @@ async def async_check_deletion_status(self) -> None: ][0] if self._data["system"].function_webhook_quotas["used"] == 0: break - await asyncio.sleep(5) + await asyncio.sleep(DELETION_POLL_INTERVAL) diff --git a/tests/components/ekeybionyx/test_config_flow.py b/tests/components/ekeybionyx/test_config_flow.py index 5c387a0398d16..9e94dbbd17004 100644 --- a/tests/components/ekeybionyx/test_config_flow.py +++ b/tests/components/ekeybionyx/test_config_flow.py @@ -250,6 +250,7 @@ async def test_no_available_webhooks( @pytest.mark.usefixtures("current_request_with_host") +@patch("homeassistant.components.ekeybionyx.config_flow.DELETION_POLL_INTERVAL", 0) async def test_cleanup( hass: HomeAssistant, hass_client_no_auth: ClientSessionGenerator, From e1a7b8780e453f53ff796c60e209eb7bccfff299 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Sat, 22 Aug 2026 09:22:35 +0200 Subject: [PATCH 05/18] Remove overbuilt traccar_server subscribe tests (#179785) Co-authored-by: Claude --- tests/components/traccar_server/test_init.py | 175 ------------------- 1 file changed, 175 deletions(-) diff --git a/tests/components/traccar_server/test_init.py b/tests/components/traccar_server/test_init.py index 5eaf7271227b7..f1825f3698d9b 100644 --- a/tests/components/traccar_server/test_init.py +++ b/tests/components/traccar_server/test_init.py @@ -4,16 +4,12 @@ from collections.abc import Awaitable, Callable from datetime import timedelta import logging -import sys from unittest.mock import AsyncMock, patch from freezegun.api import FrozenDateTimeFactory import pytest from pytraccar import SubscriptionData, TraccarAuthenticationException, TraccarException -from homeassistant.components.traccar_server.coordinator import ( - _SUBSCRIPTION_RECONNECT_DELAY, -) from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed @@ -150,76 +146,6 @@ async def _raise_auth_failure_when_ready(_callback: object) -> None: assert mock_traccar_api_client.subscribe.call_count == 1 -async def test_subscribe_does_not_recurse_across_reconnects( - hass: HomeAssistant, - mock_traccar_api_client: AsyncMock, - mock_config_entry: MockConfigEntry, -) -> None: - """Subscribe retries must not grow the call stack.""" - attempts = 0 - target_attempts = sys.getrecursionlimit() * 2 - - async def _flaky_subscribe(_callback: object) -> None: - nonlocal attempts - attempts += 1 - if attempts >= target_attempts: - # End the task deterministically, the same way an unload would. - raise asyncio.CancelledError - raise TraccarException("Simulated dropped connection") - - mock_traccar_api_client.subscribe = AsyncMock(side_effect=_flaky_subscribe) - - with patch( - "homeassistant.components.traccar_server.coordinator._SUBSCRIPTION_RECONNECT_DELAY", - 0, - ): - await setup_integration(hass, mock_config_entry) - await hass.async_block_till_done(wait_background_tasks=True) - - assert attempts == target_attempts - - -async def test_subscribe_does_not_busy_loop_on_clean_return( - hass: HomeAssistant, - mock_traccar_api_client: AsyncMock, - mock_config_entry: MockConfigEntry, -) -> None: - """If client.subscribe() ever returns without raising, still throttle. - - pytraccar's subscribe() should always raise on disconnect (see - pytraccar#477), so a clean return isn't expected in practice. But the - retry loop must not assume that - if it ever happens, reconnecting - immediately with no delay would spin the event loop at 100% CPU. - """ - calls = 0 - - async def _clean_return_then_cancel(_callback: object) -> None: - nonlocal calls - calls += 1 - if calls >= 3: - raise asyncio.CancelledError - - mock_traccar_api_client.subscribe = AsyncMock(side_effect=_clean_return_then_cancel) - - with patch( - "homeassistant.components.traccar_server.coordinator.asyncio.sleep", - new=AsyncMock(), - ) as mock_sleep: - await setup_integration(hass, mock_config_entry) - await hass.async_block_till_done(wait_background_tasks=True) - - assert calls == 3 - # Only calls 1 and 2 (the clean returns) reach the loop's delay; - # call 3 raises CancelledError before that line, so exactly two real - # reconnect delays are attributable to this code path. - reconnect_delay_sleeps = [ - call - for call in mock_sleep.await_args_list - if call.args == (_SUBSCRIPTION_RECONNECT_DELAY,) - ] - assert len(reconnect_delay_sleeps) == 2 - - async def test_subscribe_retries_on_unexpected_exception( hass: HomeAssistant, mock_traccar_api_client: AsyncMock, @@ -254,104 +180,3 @@ async def _weird_failure_then_cancel(_callback: object) -> None: await hass.async_block_till_done(wait_background_tasks=True) assert calls == 3 - - -async def test_subscribe_logs_error_once_then_periodic_reminder( - hass: HomeAssistant, - mock_traccar_api_client: AsyncMock, - mock_config_entry: MockConfigEntry, - caplog: pytest.LogCaptureFixture, -) -> None: - """The first failure logs an error; later failures throttle to a periodic warning.""" - calls = 0 - target_attempts = 61 # Crosses two 30-attempt reminder boundaries (30, 60). - - async def _always_fails(_callback: object) -> None: - nonlocal calls - calls += 1 - if calls >= target_attempts: - raise asyncio.CancelledError - raise TraccarException("Simulated dropped connection") - - mock_traccar_api_client.subscribe = AsyncMock(side_effect=_always_fails) - - with ( - patch( - "homeassistant.components.traccar_server.coordinator._SUBSCRIPTION_RECONNECT_DELAY", - 0, - ), - caplog.at_level(logging.INFO, logger="homeassistant.components.traccar_server"), - ): - await setup_integration(hass, mock_config_entry) - await hass.async_block_till_done(wait_background_tasks=True) - - error_records = [ - r - for r in caplog.records - if r.levelno == logging.ERROR - and r.name == "homeassistant.components.traccar_server" - ] - warning_records = [ - r - for r in caplog.records - if r.levelno == logging.WARNING - and r.name == "homeassistant.components.traccar_server" - ] - - assert len(error_records) == 1 - assert "Error while subscribing to Traccar" in error_records[0].message - assert len(warning_records) == 2 - assert all( - "Still unable to (re)connect to Traccar" in r.message for r in warning_records - ) - assert any("60" in r.message for r in warning_records) - - -async def test_subscribe_clean_return_resets_error_logging( - hass: HomeAssistant, - mock_traccar_api_client: AsyncMock, - mock_config_entry: MockConfigEntry, - caplog: pytest.LogCaptureFixture, -) -> None: - """A clean return re-arms error logging for the next failure streak. - - The should-log flag must reset alongside the failure counter - otherwise - a failure streak starting right after a clean return would be silently - throttled instead of logging its first error. - """ - calls = 0 - - async def _fail_then_clean_return_then_fail(_callback: object) -> None: - nonlocal calls - calls += 1 - if calls == 1: - raise TraccarException("First failure") - if calls == 2: - return # Clean return - should re-arm error logging. - if calls == 3: - raise TraccarException("Second failure, after clean return") - raise asyncio.CancelledError - - mock_traccar_api_client.subscribe = AsyncMock( - side_effect=_fail_then_clean_return_then_fail - ) - - with ( - patch( - "homeassistant.components.traccar_server.coordinator._SUBSCRIPTION_RECONNECT_DELAY", - 0, - ), - caplog.at_level(logging.INFO, logger="homeassistant.components.traccar_server"), - ): - await setup_integration(hass, mock_config_entry) - await hass.async_block_till_done(wait_background_tasks=True) - - error_records = [ - r - for r in caplog.records - if r.levelno == logging.ERROR - and r.name == "homeassistant.components.traccar_server" - ] - assert len(error_records) == 2 - assert "First failure" in error_records[0].message - assert "Second failure, after clean return" in error_records[1].message From e149a59c2dd73a43a6e7500207ad93c85f09bb0c Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Sat, 22 Aug 2026 09:24:16 +0200 Subject: [PATCH 06/18] Fix flaky concord232 polling tests (#179784) Co-authored-by: Claude --- tests/components/concord232/test_alarm_control_panel.py | 4 ++-- tests/components/concord232/test_binary_sensor.py | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/components/concord232/test_alarm_control_panel.py b/tests/components/concord232/test_alarm_control_panel.py index d10e13070a809..4af0fd882ee5e 100644 --- a/tests/components/concord232/test_alarm_control_panel.py +++ b/tests/components/concord232/test_alarm_control_panel.py @@ -237,7 +237,7 @@ async def test_update_state_armed( # Trigger update freezer.tick(10) async_fire_time_changed(hass) - await hass.async_block_till_done() + await hass.async_block_till_done(wait_background_tasks=True) state = hass.states.get("alarm_control_panel.test_alarm") assert state.state == expected_state @@ -259,7 +259,7 @@ async def test_update_connection_error( freezer.tick(10) async_fire_time_changed(hass) - await hass.async_block_till_done() + await hass.async_block_till_done(wait_background_tasks=True) assert "Unable to connect to" in caplog.text diff --git a/tests/components/concord232/test_binary_sensor.py b/tests/components/concord232/test_binary_sensor.py index 8a226fd41e097..f5e9e47ccbb4d 100644 --- a/tests/components/concord232/test_binary_sensor.py +++ b/tests/components/concord232/test_binary_sensor.py @@ -153,11 +153,11 @@ async def test_zone_update_refresh( freezer.tick(datetime.timedelta(seconds=10)) async_fire_time_changed(hass) - await hass.async_block_till_done() + await hass.async_block_till_done(wait_background_tasks=True) freezer.tick(datetime.timedelta(seconds=10)) async_fire_time_changed(hass) - await hass.async_block_till_done() + await hass.async_block_till_done(wait_background_tasks=True) state = hass.states.get("binary_sensor.zone_1") assert state.state == "on" From d6e083bf076a85b8ad101ad67922682ccd5b6f84 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Sat, 22 Aug 2026 09:26:21 +0200 Subject: [PATCH 07/18] Skip hassfest MDI icon generation on an outdated frontend (#179781) Co-authored-by: Claude --- script/hassfest/mdi_icons.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/script/hassfest/mdi_icons.py b/script/hassfest/mdi_icons.py index 3f8b2882942ce..d24f20532c00f 100644 --- a/script/hassfest/mdi_icons.py +++ b/script/hassfest/mdi_icons.py @@ -8,6 +8,7 @@ from .serializer import format_python_namespace _TARGET = "pylint/plugins/pylint_home_assistant/generated/mdi_icons.py" +_REQUIREMENT_PREFIX = "home-assistant-frontend==" def _get_frontend_version() -> str | None: @@ -18,6 +19,14 @@ def _get_frontend_version() -> str | None: return None +def _get_pinned_frontend_version(integrations: dict[str, Integration]) -> str | None: + """Get the home-assistant-frontend version pinned in the frontend manifest.""" + for requirement in integrations["frontend"].requirements: + if requirement.startswith(_REQUIREMENT_PREFIX): + return requirement.removeprefix(_REQUIREMENT_PREFIX) + return None + + def _load_mdi_icons() -> set[str]: """Load the MDI icon names from the frontend package.""" try: @@ -35,6 +44,10 @@ def validate(integrations: dict[str, Integration], config: Config) -> None: if frontend_version is None: return + pinned_version = _get_pinned_frontend_version(integrations) + if pinned_version is not None and pinned_version != frontend_version: + return + icons = _load_mdi_icons() if not icons: config.add_error( From 15844798c57401740cc27f3a526203b9e8f33123 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Sat, 22 Aug 2026 09:27:45 +0200 Subject: [PATCH 08/18] Mock entry setup in cielo_home config flow tests (#179782) Co-authored-by: Claude --- tests/components/cielo_home/test_config_flow.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tests/components/cielo_home/test_config_flow.py b/tests/components/cielo_home/test_config_flow.py index d678b4ff9d10e..053a61b9f31da 100644 --- a/tests/components/cielo_home/test_config_flow.py +++ b/tests/components/cielo_home/test_config_flow.py @@ -27,6 +27,7 @@ def _devices_payload(parsed: dict | None) -> MagicMock: return payload +@pytest.mark.usefixtures("mock_setup_entry") async def test_full_config_flow_success(hass: HomeAssistant) -> None: """Test successful config flow with valid API key.""" mock_client = MagicMock() @@ -89,6 +90,7 @@ async def test_full_config_flow_abort_already_configured( (Exception, "unknown"), ], ) +@pytest.mark.usefixtures("mock_setup_entry") async def test_form_error_mapping( hass: HomeAssistant, api_error: type[Exception], flow_error_key: str ) -> None: @@ -127,6 +129,7 @@ async def test_form_error_mapping( assert result3["type"] is FlowResultType.CREATE_ENTRY +@pytest.mark.usefixtures("mock_setup_entry") async def test_form_error_mapping_invalid_auth(hass: HomeAssistant) -> None: """Test AuthenticationError maps to invalid_auth.""" From 9183a11178899370606233c1a5a072e571c97dbe Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Sat, 22 Aug 2026 09:29:02 +0200 Subject: [PATCH 09/18] Fix owner of the all-lights grouped light in the Hue v2 test fixture (#179787) Co-authored-by: Claude --- tests/components/hue/fixtures/v2_resources.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/components/hue/fixtures/v2_resources.json b/tests/components/hue/fixtures/v2_resources.json index 831a499bd5935..b52ab82b5be84 100644 --- a/tests/components/hue/fixtures/v2_resources.json +++ b/tests/components/hue/fixtures/v2_resources.json @@ -1509,8 +1509,8 @@ "on": true }, "owner": { - "rid": "7cee478d-6455-483a-9e32-9f9fdcbcc4f6", - "rtype": "zone" + "rid": "a3fbc86a-bf4c-4c69-899d-d6eafc37e288", + "rtype": "bridge_home" }, "type": "grouped_light" }, From b9c9e7aa888f5ec45557b4dc69dc05396493c89b Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Sat, 22 Aug 2026 09:33:52 +0200 Subject: [PATCH 10/18] Patch MAP_SLEEP in the Roborock selected map test (#179790) Co-authored-by: Claude --- tests/components/roborock/test_select.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/components/roborock/test_select.py b/tests/components/roborock/test_select.py index c8a70293e9063..68e99e67fc8e3 100644 --- a/tests/components/roborock/test_select.py +++ b/tests/components/roborock/test_select.py @@ -2,7 +2,7 @@ import copy from typing import Any -from unittest.mock import AsyncMock, Mock, call +from unittest.mock import AsyncMock, Mock, call, patch import pytest from roborock import CleanTypeMapping, RoborockCommand @@ -87,6 +87,7 @@ async def test_update_success( ("select.roborock_s7_maxv_selected_map", "Downstairs"), ], ) +@patch("homeassistant.components.roborock.select.MAP_SLEEP", 0) async def test_update_success_selected_map( hass: HomeAssistant, setup_entry: MockConfigEntry, From b2e7fea022d8088c29296642a7888449ca35a9d5 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Sat, 22 Aug 2026 09:34:59 +0200 Subject: [PATCH 11/18] Name the JVC Projector delay between a power command and the refresh (#179791) Co-authored-by: Claude --- homeassistant/components/jvc_projector/remote.py | 6 ++++-- tests/components/jvc_projector/test_remote.py | 3 ++- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/jvc_projector/remote.py b/homeassistant/components/jvc_projector/remote.py index ecb2320fa3ced..f063fbd61ea0c 100644 --- a/homeassistant/components/jvc_projector/remote.py +++ b/homeassistant/components/jvc_projector/remote.py @@ -15,6 +15,8 @@ from .coordinator import JVCConfigEntry from .entity import JvcProjectorEntity +POWER_SLEEP = 1 + COMMANDS: list[str] = [ cmd.Remote.MENU, cmd.Remote.UP, @@ -92,14 +94,14 @@ def is_on(self) -> bool: async def async_turn_on(self, **kwargs: Any) -> None: """Turn the device on.""" await self.device.set(cmd.Power, cmd.Power.ON) - await asyncio.sleep(1) + await asyncio.sleep(POWER_SLEEP) await self.coordinator.async_refresh() @override async def async_turn_off(self, **kwargs: Any) -> None: """Turn the device off.""" await self.device.set(cmd.Power, cmd.Power.OFF) - await asyncio.sleep(1) + await asyncio.sleep(POWER_SLEEP) await self.coordinator.async_refresh() @override diff --git a/tests/components/jvc_projector/test_remote.py b/tests/components/jvc_projector/test_remote.py index 0ba2f18fe1ac7..b70501ab79dab 100644 --- a/tests/components/jvc_projector/test_remote.py +++ b/tests/components/jvc_projector/test_remote.py @@ -1,6 +1,6 @@ """Tests for JVC Projector remote platform.""" -from unittest.mock import MagicMock +from unittest.mock import MagicMock, patch import pytest @@ -31,6 +31,7 @@ async def test_entity_state( assert entity_registry.async_get(entity.entity_id) +@patch("homeassistant.components.jvc_projector.remote.POWER_SLEEP", 0) async def test_commands( hass: HomeAssistant, mock_device: MagicMock, From d39e5d0bfb69a64a362ffc026f5fe01e196e4db2 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Sat, 22 Aug 2026 09:44:46 +0200 Subject: [PATCH 12/18] Report slow tests by duration instead of by rank (#179792) Co-authored-by: Claude --- .github/workflows/ci.yaml | 15 ++++----------- 1 file changed, 4 insertions(+), 11 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 6efa808ce1949..0594c8bf983fc 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -861,9 +861,6 @@ jobs: - name: Register Python problem matcher run: | echo "::add-matcher::.github/workflows/matchers/python.json" - - name: Register pytest slow test problem matcher - run: | - echo "::add-matcher::.github/workflows/matchers/pytest-slow.json" - name: Download pytest_buckets uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: @@ -895,7 +892,8 @@ jobs: python3 -b -X dev -m pytest \ -qq \ --timeout=9 \ - --durations=10 \ + --durations=0 \ + --durations-min=1 \ --numprocesses auto \ --snapshot-details \ --dist=loadfile \ @@ -997,9 +995,6 @@ jobs: - name: Register Python problem matcher run: | echo "::add-matcher::.github/workflows/matchers/python.json" - - name: Register pytest slow test problem matcher - run: | - echo "::add-matcher::.github/workflows/matchers/pytest-slow.json" - name: Install SQL Python libraries run: | . venv/bin/activate @@ -1038,7 +1033,8 @@ jobs: --snapshot-details \ ${cov_params[@]} \ -o console_output_style=count \ - --durations=10 \ + --durations=0 \ + --durations-min=10 \ -p no:sugar \ --exclude-warning-annotations \ --dburl=mysql://root:password@127.0.0.1/homeassistant-test \ @@ -1150,9 +1146,6 @@ jobs: - name: Register Python problem matcher run: | echo "::add-matcher::.github/workflows/matchers/python.json" - - name: Register pytest slow test problem matcher - run: | - echo "::add-matcher::.github/workflows/matchers/pytest-slow.json" - name: Install SQL Python libraries run: | . venv/bin/activate From 7aca54bbefff254fc04d696336d826edb5bb5f47 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Sat, 22 Aug 2026 09:46:24 +0200 Subject: [PATCH 13/18] Remove a dead sleep from the Insteon properties test (#179793) Co-authored-by: Claude --- tests/components/insteon/test_api_properties.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/tests/components/insteon/test_api_properties.py b/tests/components/insteon/test_api_properties.py index 2d15132e5ffbe..793933564456c 100644 --- a/tests/components/insteon/test_api_properties.py +++ b/tests/components/insteon/test_api_properties.py @@ -1,6 +1,5 @@ """Test the Insteon properties APIs.""" -import asyncio import json from typing import Any from unittest.mock import AsyncMock, patch @@ -157,7 +156,6 @@ async def test_get_read_only_properties( msg = await ws_client.receive_json() assert msg["success"] assert len(msg["result"]["properties"]) == 15 - await asyncio.sleep(1) async def test_get_unknown_properties( From 2f30412ea63eac0bcadc930d6f52f70aa2722d9f Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Sat, 22 Aug 2026 09:47:53 +0200 Subject: [PATCH 14/18] Name the Motion Blinds per-blind update delay (#179794) Co-authored-by: Claude --- homeassistant/components/motion_blinds/const.py | 1 + homeassistant/components/motion_blinds/coordinator.py | 3 ++- tests/components/motion_blinds/test_init.py | 3 +++ 3 files changed, 6 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/motion_blinds/const.py b/homeassistant/components/motion_blinds/const.py index 1d151a1e63bc1..e95a389c8c9df 100644 --- a/homeassistant/components/motion_blinds/const.py +++ b/homeassistant/components/motion_blinds/const.py @@ -29,5 +29,6 @@ UPDATE_INTERVAL = 600 UPDATE_INTERVAL_FAST = 60 UPDATE_DELAY_STOP = 3 +UPDATE_DELAY_BLIND = 1.5 UPDATE_INTERVAL_MOVING = 5 UPDATE_INTERVAL_MOVING_WIFI = 45 diff --git a/homeassistant/components/motion_blinds/coordinator.py b/homeassistant/components/motion_blinds/coordinator.py index abc65871ae002..e1fe61526d556 100644 --- a/homeassistant/components/motion_blinds/coordinator.py +++ b/homeassistant/components/motion_blinds/coordinator.py @@ -16,6 +16,7 @@ CONF_WAIT_FOR_PUSH, DEFAULT_WAIT_FOR_PUSH, KEY_GATEWAY, + UPDATE_DELAY_BLIND, UPDATE_INTERVAL, UPDATE_INTERVAL_FAST, ) @@ -89,7 +90,7 @@ async def _async_update_data(self): ) for blind in self.gateway.device_list.values(): - await asyncio.sleep(1.5) + await asyncio.sleep(UPDATE_DELAY_BLIND) async with self.api_lock: data[blind.mac] = await self.hass.async_add_executor_job( self.update_blind, blind diff --git a/tests/components/motion_blinds/test_init.py b/tests/components/motion_blinds/test_init.py index 8a49ad305f5d2..03052507f175a 100644 --- a/tests/components/motion_blinds/test_init.py +++ b/tests/components/motion_blinds/test_init.py @@ -51,6 +51,9 @@ def mock_gateway_fixture() -> Mock: def mock_connect_fixture(mock_gateway: Mock) -> Generator[None]: """Mock the connection to the Motion gateway.""" with ( + patch( + "homeassistant.components.motion_blinds.coordinator.UPDATE_DELAY_BLIND", 0 + ), patch( "homeassistant.components.motion_blinds.AsyncMotionMulticast" ) as multicast_class, From 4ce0b20d7011019b9d8745980be1c3491803da7a Mon Sep 17 00:00:00 2001 From: Shay Levy Date: Sat, 22 Aug 2026 11:37:59 +0300 Subject: [PATCH 15/18] Fix LG webOS TV media playback channel matching (#179796) --- .../components/webostv/media_player.py | 9 ++-- tests/components/webostv/test_media_player.py | 42 +++++++++++++++++++ 2 files changed, 48 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/webostv/media_player.py b/homeassistant/components/webostv/media_player.py index 5da387f1532ef..571080a17b310 100644 --- a/homeassistant/components/webostv/media_player.py +++ b/homeassistant/components/webostv/media_player.py @@ -340,11 +340,14 @@ async def async_play_media( perfect_match_channel_id = None for channel in self._client.tv_state.channels: - if media_id == channel["channelNumber"]: + if media_id.lower() == channel["channelName"].lower(): perfect_match_channel_id = channel["channelId"] - continue + break - if media_id.lower() == channel["channelName"].lower(): + if ( + media_id == channel["channelNumber"] + and perfect_match_channel_id is None + ): perfect_match_channel_id = channel["channelId"] continue diff --git a/tests/components/webostv/test_media_player.py b/tests/components/webostv/test_media_player.py index 5e541e5b3205b..bd6f97e18f66a 100644 --- a/tests/components/webostv/test_media_player.py +++ b/tests/components/webostv/test_media_player.py @@ -416,6 +416,48 @@ async def test_play_media(hass: HomeAssistant, client, media_id, ch_id) -> None: client.set_channel.assert_called_once_with(ch_id) +async def test_play_media_channel_name_over_number(hass: HomeAssistant, client) -> None: + """Test that an exact channel name match takes precedence over a channel number match.""" + await setup_webostv(hass) + await client.mock_state_update() + + client.tv_state.channels = [ + {"channelNumber": "1", "channelName": "20", "channelId": "ch_name_match"}, + {"channelNumber": "20", "channelName": "Ch 20", "channelId": "ch_number_match"}, + ] + + data = { + ATTR_ENTITY_ID: ENTITY_ID, + ATTR_MEDIA_CONTENT_TYPE: MediaType.CHANNEL, + ATTR_MEDIA_CONTENT_ID: "20", + } + await hass.services.async_call(MP_DOMAIN, SERVICE_PLAY_MEDIA, data, True) + + client.set_channel.assert_called_once_with("ch_name_match") + + +async def test_play_media_duplicate_channel_number_selects_first( + hass: HomeAssistant, client +) -> None: + """Test that the first channel is selected when two channels share the same number.""" + await setup_webostv(hass) + await client.mock_state_update() + + client.tv_state.channels = [ + {"channelNumber": "5", "channelName": "TV Channel", "channelId": "ch_first"}, + {"channelNumber": "5", "channelName": "Radio Channel", "channelId": "ch_last"}, + ] + + data = { + ATTR_ENTITY_ID: ENTITY_ID, + ATTR_MEDIA_CONTENT_TYPE: MediaType.CHANNEL, + ATTR_MEDIA_CONTENT_ID: "5", + } + await hass.services.async_call(MP_DOMAIN, SERVICE_PLAY_MEDIA, data, True) + + client.set_channel.assert_called_once_with("ch_first") + + async def test_update_sources_live_tv_find(hass: HomeAssistant, client) -> None: """Test finding live TV app id in update sources.""" await setup_webostv(hass) From 8febbae4361da3d37c9aea3fe664e5f133ed27d7 Mon Sep 17 00:00:00 2001 From: Jens Timmerman <281523+JensTimmerman@users.noreply.github.com> Date: Sat, 22 Aug 2026 10:40:33 +0200 Subject: [PATCH 16/18] Bump guntamatic to v1.11.1 (#179773) --- homeassistant/components/guntamatic/manifest.json | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/guntamatic/manifest.json b/homeassistant/components/guntamatic/manifest.json index 2416bc0e07c5d..f377576bd1ee6 100644 --- a/homeassistant/components/guntamatic/manifest.json +++ b/homeassistant/components/guntamatic/manifest.json @@ -14,5 +14,5 @@ "integration_type": "device", "iot_class": "local_polling", "quality_scale": "silver", - "requirements": ["guntamatic==1.11.0"] + "requirements": ["guntamatic==1.11.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index daa56831b5313..a8193ac933d9c 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1201,7 +1201,7 @@ growattServer==2.1.0 gspread==5.5.0 # homeassistant.components.guntamatic -guntamatic==1.11.0 +guntamatic==1.11.1 # homeassistant.components.profiler guppy3==3.1.7 From c32573558b35b407ceed9ea9580686544d960b32 Mon Sep 17 00:00:00 2001 From: IceBotYT <34712694+IceBotYT@users.noreply.github.com> Date: Sat, 22 Aug 2026 04:41:34 -0400 Subject: [PATCH 17/18] Handle AuthFailedError in Nice G.O. WebSocket connection (#179753) --- .../components/nice_go/coordinator.py | 39 +++++++++++++------ tests/components/nice_go/test_init.py | 33 ++++++++++++++++ 2 files changed, 60 insertions(+), 12 deletions(-) diff --git a/homeassistant/components/nice_go/coordinator.py b/homeassistant/components/nice_go/coordinator.py index a680bee39d291..2903091620c92 100644 --- a/homeassistant/components/nice_go/coordinator.py +++ b/homeassistant/components/nice_go/coordinator.py @@ -147,17 +147,8 @@ async def _async_update_data(self) -> dict[str, NiceGODevice]: async def _async_setup(self) -> None: """Set up the coordinator.""" async with asyncio.timeout(10): - expiry_time = ( - self.refresh_token_creation_time - + REFRESH_TOKEN_EXPIRY_TIME.total_seconds() - ) try: - if datetime.now().timestamp() >= expiry_time: # pylint: disable=home-assistant-enforce-naive-now - await self.update_refresh_token() - else: - await self.api.authenticate_refresh( - self.refresh_token, async_get_clientsession(self.hass) - ) + await self.authenticate() _LOGGER.debug("Authenticated with Nice G.O. API") barriers = await self.api.get_all_barriers() @@ -171,13 +162,31 @@ async def _async_setup(self) -> None: barrier.id: barrier for barrier in parsed_barriers if barrier } self.organization_id = await barriers[0].get_attr("organization") - except AuthFailedError as e: - raise ConfigEntryAuthFailed from e except ApiError as e: raise UpdateFailed from e else: self.async_set_updated_data(devices) + async def authenticate(self) -> None: + """Authenticate with the Nice G.O. API.""" + _LOGGER.debug("Authenticating with Nice G.O. API") + expiry_time = ( + self.refresh_token_creation_time + REFRESH_TOKEN_EXPIRY_TIME.total_seconds() + ) + try: + if datetime.now().timestamp() >= expiry_time: # pylint: disable=home-assistant-enforce-naive-now + await self.update_refresh_token() + else: + await self.api.authenticate_refresh( + self.refresh_token, async_get_clientsession(self.hass) + ) + except AuthFailedError as e: + _LOGGER.exception("Authentication failed") + raise ConfigEntryAuthFailed from e + except ApiError as e: + _LOGGER.exception("API error") + raise UpdateFailed from e + async def update_refresh_token(self) -> None: """Update the refresh token with Nice G.O. API.""" _LOGGER.debug("Updating the refresh token with Nice G.O. API") @@ -214,6 +223,12 @@ async def client_listen(self) -> None: try: await self.api.connect(reconnect=True) + except AuthFailedError: + # Try reauthenticating otherwise start reauth flow + _LOGGER.debug( + "Got auth failed when connecting to websocket, trying to reauthenticate" + ) + await self.authenticate() except ApiError: _LOGGER.exception("API error") else: diff --git a/tests/components/nice_go/test_init.py b/tests/components/nice_go/test_init.py index b1aa01ee36049..c2d69167cba17 100644 --- a/tests/components/nice_go/test_init.py +++ b/tests/components/nice_go/test_init.py @@ -40,6 +40,19 @@ async def test_setup_failure_api_error( ) -> None: """Test reauth trigger setup.""" + mock_nice_go.get_all_barriers.side_effect = ApiError() + + await setup_integration(hass, mock_config_entry, []) + assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY + + +async def test_auth_api_error( + hass: HomeAssistant, + mock_nice_go: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test reauth trigger setup.""" + mock_nice_go.authenticate_refresh.side_effect = ApiError() await setup_integration(hass, mock_config_entry, []) @@ -206,6 +219,26 @@ async def test_client_listen_api_error( assert mock_nice_go.connect.call_count == 2 +async def test_client_listen_auth_failed( + hass: HomeAssistant, + mock_nice_go: AsyncMock, + mock_config_entry: MockConfigEntry, + caplog: pytest.LogCaptureFixture, + freezer: FrozenDateTimeFactory, +) -> None: + """Test client listen with error.""" + + mock_nice_go.connect.side_effect = AuthFailedError + + await setup_integration(hass, mock_config_entry, [Platform.COVER]) + + assert ( + "Got auth failed when connecting to websocket, trying to reauthenticate" + in caplog.text + ) + assert mock_nice_go.authenticate_refresh.call_count == 2 + + async def test_on_data_none_parsed( hass: HomeAssistant, mock_nice_go: AsyncMock, From 4130e1697794872d0bff1f21b3b47fd818d7a85b Mon Sep 17 00:00:00 2001 From: Martin Hjelmare Date: Sat, 22 Aug 2026 10:46:16 +0200 Subject: [PATCH 18/18] Add config flow to remember_the_milk (#178808) --- .../components/remember_the_milk/__init__.py | 256 ++++++++++------- .../remember_the_milk/config_flow.py | 180 ++++++++++++ .../components/remember_the_milk/const.py | 2 + .../components/remember_the_milk/entity.py | 135 +++++---- .../remember_the_milk/manifest.json | 7 +- .../components/remember_the_milk/storage.py | 53 ++-- .../components/remember_the_milk/strings.json | 44 +++ homeassistant/generated/config_flows.py | 1 + homeassistant/generated/integrations.json | 4 +- requirements_all.txt | 9 +- .../components/remember_the_milk/conftest.py | 92 ++++-- tests/components/remember_the_milk/const.py | 28 +- .../remember_the_milk/test_config_flow.py | 270 ++++++++++++++++++ .../remember_the_milk/test_entity.py | 176 +++++++++--- .../components/remember_the_milk/test_init.py | 137 ++++++--- .../remember_the_milk/test_storage.py | 87 +++--- 16 files changed, 1121 insertions(+), 360 deletions(-) create mode 100644 homeassistant/components/remember_the_milk/config_flow.py create mode 100644 tests/components/remember_the_milk/test_config_flow.py diff --git a/homeassistant/components/remember_the_milk/__init__.py b/homeassistant/components/remember_the_milk/__init__.py index df9eec0622f1f..1cec425caf770 100644 --- a/homeassistant/components/remember_the_milk/__init__.py +++ b/homeassistant/components/remember_the_milk/__init__.py @@ -1,26 +1,33 @@ -"""Support to interact with Remember The Milk.""" +"""The Remember The Milk integration.""" -from rtmapi import Rtm +from copy import deepcopy +from dataclasses import dataclass +from typing import Any + +from aiortm import AioRTMClient, AioRTMError, Auth, AuthError import voluptuous as vol -from homeassistant.components import configurator -from homeassistant.const import CONF_API_KEY, CONF_ID, CONF_NAME -from homeassistant.core import HomeAssistant +from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry +from homeassistant.const import ( + CONF_API_KEY, + CONF_ID, + CONF_NAME, + CONF_TOKEN, + CONF_USERNAME, +) +from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant +from homeassistant.data_entry_flow import FlowResultType +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.entity_component import EntityComponent +from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from homeassistant.helpers.typing import ConfigType -from .const import LOGGER +from .const import CONF_SHARED_SECRET, DOMAIN, LOGGER from .entity import RememberTheMilkEntity from .storage import RememberTheMilkConfiguration -# httplib2 is a transitive dependency from RtmAPI. If this dependency is not -# set explicitly, the library does not work. - -DOMAIN = "remember_the_milk" - -CONF_SHARED_SECRET = "shared_secret" - RTM_SCHEMA = vol.Schema( { vol.Required(CONF_NAME): cv.string, @@ -42,114 +49,161 @@ SERVICE_SCHEMA_COMPLETE_TASK = vol.Schema({vol.Required(CONF_ID): cv.string}) +DATA_COMPONENT = "component" +DATA_STORAGE = "storage" + +type RememberTheMilkConfigEntry = ConfigEntry[RememberTheMilkData] + + +@dataclass +class RememberTheMilkData: + """Runtime data for a Remember The Milk config entry.""" + + entity_id: str -def setup(hass: HomeAssistant, config: ConfigType) -> bool: + +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the Remember the milk component.""" - component = EntityComponent[RememberTheMilkEntity](LOGGER, DOMAIN, hass) - - stored_rtm_config = RememberTheMilkConfiguration(hass) - for rtm_config in config[DOMAIN]: - account_name = rtm_config[CONF_NAME] - LOGGER.debug("Adding Remember the milk account %s", account_name) - api_key = rtm_config[CONF_API_KEY] - shared_secret = rtm_config[CONF_SHARED_SECRET] - token = stored_rtm_config.get_token(account_name) - if token: - LOGGER.debug("found token for account %s", account_name) - _create_instance( - hass, - account_name, - api_key, - shared_secret, - token, - stored_rtm_config, - component, - ) - else: - _register_new_account( - hass, account_name, api_key, shared_secret, stored_rtm_config, component - ) - - LOGGER.debug("Finished adding all Remember the milk accounts") + # pylint: disable-next=home-assistant-use-runtime-data + hass.data[DOMAIN] = {} + # pylint: disable-next=home-assistant-use-runtime-data + hass.data[DOMAIN][DATA_COMPONENT] = EntityComponent[RememberTheMilkEntity]( + LOGGER, DOMAIN, hass + ) + # pylint: disable-next=home-assistant-use-runtime-data + storage = hass.data[DOMAIN][DATA_STORAGE] = RememberTheMilkConfiguration(hass) + await hass.async_add_executor_job(storage.setup) + if DOMAIN not in config: + return True + + for rtm_config in deepcopy(config[DOMAIN]): + hass.async_create_task(_async_import(hass, storage, rtm_config)) return True -def _create_instance( +async def _async_import( hass: HomeAssistant, - account_name: str, - api_key: str, - shared_secret: str, - token: str, - stored_rtm_config: RememberTheMilkConfiguration, - component: EntityComponent[RememberTheMilkEntity], + storage: RememberTheMilkConfiguration, + rtm_config: dict[str, Any], ) -> None: + """Import a YAML configured account and create a repair issue.""" + name = rtm_config[CONF_NAME] + token = storage.get_token(name) + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_IMPORT}, + data=rtm_config | {CONF_TOKEN: token}, + ) + if ( + result["type"] is FlowResultType.ABORT + and result["reason"] != "already_configured" + ): + async_create_issue( + hass, + DOMAIN, + f"deprecated_yaml_import_issue_{result['reason']}", + breaks_in_ha_version="2027.3.0", + is_fixable=False, + issue_domain=DOMAIN, + severity=IssueSeverity.WARNING, + translation_key=f"deprecated_yaml_import_issue_{result['reason']}", + translation_placeholders={ + "domain": DOMAIN, + "integration_title": "Remember The Milk", + }, + ) + return + + async_create_issue( + hass, + HOMEASSISTANT_DOMAIN, + f"deprecated_yaml_{DOMAIN}", + breaks_in_ha_version="2027.3.0", + is_fixable=False, + issue_domain=DOMAIN, + severity=IssueSeverity.WARNING, + translation_key="deprecated_yaml", + translation_placeholders={ + "domain": DOMAIN, + "integration_title": "Remember The Milk", + }, + ) + + +async def async_setup_entry( + hass: HomeAssistant, entry: RememberTheMilkConfigEntry +) -> bool: + """Set up Remember The Milk from a config entry.""" + # pylint: disable-next=home-assistant-use-runtime-data + component: EntityComponent[RememberTheMilkEntity] = hass.data[DOMAIN][ + DATA_COMPONENT + ] + # pylint: disable-next=home-assistant-use-runtime-data + storage: RememberTheMilkConfiguration = hass.data[DOMAIN][DATA_STORAGE] + + rtm_config = entry.data + account_name: str = rtm_config[CONF_USERNAME] + LOGGER.debug("Adding Remember the milk account %s", account_name) + api_key: str = rtm_config[CONF_API_KEY] + shared_secret: str = rtm_config[CONF_SHARED_SECRET] + token: str = rtm_config[CONF_TOKEN] + client = AioRTMClient( + Auth( + client_session=async_get_clientsession(hass), + api_key=api_key, + shared_secret=shared_secret, + auth_token=token, + permission="delete", + ) + ) + + token_valid = True + try: + await client.rtm.api.check_token() + except AuthError: + token_valid = False + except AioRTMError as err: + raise ConfigEntryNotReady from err + + # The entity will be deprecated when a todo platform is added. entity = RememberTheMilkEntity( - account_name, api_key, shared_secret, token, stored_rtm_config + name=account_name, + client=client, + config_entry_id=entry.entry_id, + storage=storage, + token_valid=token_valid, ) - component.add_entities([entity]) - hass.services.register( + await component.async_add_entities([entity]) + entry.runtime_data = RememberTheMilkData(entity_id=entity.entity_id) + + # The services are registered here for now because they need the account name. + # The services will be deprecated when a todo platform is added. + # pylint: disable=home-assistant-service-registered-in-setup-entry + hass.services.async_register( DOMAIN, f"{account_name}_create_task", entity.create_task, schema=SERVICE_SCHEMA_CREATE_TASK, ) - hass.services.register( + hass.services.async_register( DOMAIN, f"{account_name}_complete_task", entity.complete_task, schema=SERVICE_SCHEMA_COMPLETE_TASK, ) + if not token_valid: + raise ConfigEntryAuthFailed("Invalid token") -def _register_new_account( - hass: HomeAssistant, - account_name: str, - api_key: str, - shared_secret: str, - stored_rtm_config: RememberTheMilkConfiguration, - component: EntityComponent[RememberTheMilkEntity], -) -> None: - api = Rtm(api_key, shared_secret, "write", None) - url, frob = api.authenticate_desktop() - LOGGER.debug("Sent authentication request to server") - - def register_account_callback(fields: list[dict[str, str]]) -> None: - """Call for register the configurator.""" - api.retrieve_token(frob) - token = api.token - if api.token is None: - LOGGER.error("Failed to register, please try again") - configurator.notify_errors( - hass, request_id, "Failed to register, please try again." - ) - return - - stored_rtm_config.set_token(account_name, token) - LOGGER.debug("Retrieved new token from server") - - _create_instance( - hass, - account_name, - api_key, - shared_secret, - token, - stored_rtm_config, - component, - ) + return True - configurator.request_done(hass, request_id) - request_id = configurator.request_config( - hass, - f"{DOMAIN} - {account_name}", - callback=register_account_callback, - description=( - "You need to log in to Remember The Milk to" - "connect your account. \n\n" - "Step 1: Click on the link 'Remember The Milk login'\n\n" - "Step 2: Click on 'login completed'" - ), - link_name="Remember The Milk login", - link_url=url, - submit_caption="login completed", - ) +async def async_unload_entry( + hass: HomeAssistant, entry: RememberTheMilkConfigEntry +) -> bool: + """Unload a config entry.""" + component: EntityComponent[RememberTheMilkEntity] = hass.data[DOMAIN][ + DATA_COMPONENT + ] + await component.async_remove_entity(entry.runtime_data.entity_id) + return True diff --git a/homeassistant/components/remember_the_milk/config_flow.py b/homeassistant/components/remember_the_milk/config_flow.py new file mode 100644 index 0000000000000..641262a7ce505 --- /dev/null +++ b/homeassistant/components/remember_the_milk/config_flow.py @@ -0,0 +1,180 @@ +"""Config flow for Remember The Milk integration.""" + +import asyncio +from typing import Any, override + +from aiortm import AioRTMError, Auth, AuthError +import voluptuous as vol + +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.const import CONF_API_KEY, CONF_NAME, CONF_TOKEN, CONF_USERNAME +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.selector import ( + TextSelector, + TextSelectorConfig, + TextSelectorType, +) + +from .const import CONF_SHARED_SECRET, DOMAIN, LOGGER + +TOKEN_TIMEOUT_SEC = 30 + +STEP_USER_DATA_SCHEMA = vol.Schema( + { + vol.Required(CONF_API_KEY): TextSelector( + TextSelectorConfig(type=TextSelectorType.PASSWORD) + ), + vol.Required(CONF_SHARED_SECRET): TextSelector( + TextSelectorConfig(type=TextSelectorType.PASSWORD) + ), + } +) + + +class RTMConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle a config flow for Remember The Milk.""" + + VERSION = 1 + + def __init__(self) -> None: + """Initialize the config flow.""" + self._auth: Auth | None = None + self._url: str | None = None + self._frob: str | None = None + self._auth_credentials: dict[str, str] | None = None + + def _get_auth( + self, api_key: str, shared_secret: str, token: str | None = None + ) -> Auth: + """Return an Auth client for the given credentials.""" + return Auth( + client_session=async_get_clientsession(self.hass), + api_key=api_key, + shared_secret=shared_secret, + auth_token=token, + permission="delete", + ) + + @override + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle the initial step.""" + errors: dict[str, str] = {} + if user_input is not None: + self._auth_credentials = user_input + auth = self._auth = self._get_auth( + user_input[CONF_API_KEY], user_input[CONF_SHARED_SECRET] + ) + try: + self._url, self._frob = await auth.authenticate_desktop() + except AuthError: + errors["base"] = "invalid_auth" + except AioRTMError: + errors["base"] = "cannot_connect" + except Exception: # noqa: BLE001 pylint: disable=broad-except + LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + else: + return await self.async_step_auth() + + return self.async_show_form( + step_id="user", + data_schema=self.add_suggested_values_to_schema( + STEP_USER_DATA_SCHEMA, + user_input, + ), + errors=errors, + ) + + async def async_step_auth( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Authorize the application.""" + assert self._url is not None + if user_input is not None: + return await self._get_token() + + return self.async_show_form( + step_id="auth", description_placeholders={"url": self._url} + ) + + async def _get_token(self) -> ConfigFlowResult: + """Get token and create config entry.""" + assert self._auth is not None + assert self._frob is not None + assert self._auth_credentials is not None + try: + async with asyncio.timeout(TOKEN_TIMEOUT_SEC): + token_data = await self._auth.get_token(self._frob) + except TimeoutError: + return self.async_abort(reason="timeout_token") + except AuthError: + return self.async_abort(reason="invalid_auth") + except AioRTMError: + return self.async_abort(reason="cannot_connect") + except Exception: # noqa: BLE001 pylint: disable=broad-except + LOGGER.exception("Unexpected exception") + return self.async_abort(reason="unknown") + + return await self._async_create_entry( + token_data, + self._auth_credentials[CONF_API_KEY], + self._auth_credentials[CONF_SHARED_SECRET], + ) + + async def _async_create_entry( + self, + token_data: dict[str, Any], + api_key: str, + shared_secret: str, + ) -> ConfigFlowResult: + """Create the config entry from token data. + + The token data has the same structure whether it comes from get_token + or check_token. + """ + await self.async_set_unique_id(token_data["user"]["id"]) + self._abort_if_unique_id_configured() + return self.async_create_entry( + title=token_data["user"]["fullname"], + data={ + CONF_API_KEY: api_key, + CONF_SHARED_SECRET: shared_secret, + CONF_TOKEN: token_data["token"], + CONF_USERNAME: token_data["user"]["username"], + }, + ) + + async def async_step_import(self, import_info: dict[str, Any]) -> ConfigFlowResult: + """Import a config entry from YAML. + + The token, looked up from legacy storage in async_setup, is passed in + the import data. Without a valid token the import is aborted so the user + sets the integration up via the UI. A repair issue is raised in + async_setup for both the success and failure cases. + """ + name = import_info.pop(CONF_NAME) + self._async_abort_entries_match({CONF_USERNAME: name}) + token = import_info.get(CONF_TOKEN) + if token is None: + return self.async_abort(reason="invalid_auth") + auth = self._get_auth( + import_info[CONF_API_KEY], import_info[CONF_SHARED_SECRET], token + ) + try: + token_data = await auth.check_token() + except AuthError: + return self.async_abort(reason="invalid_auth") + except AioRTMError: + return self.async_abort(reason="cannot_connect") + except Exception: # noqa: BLE001 pylint: disable=broad-except + LOGGER.exception("Unexpected exception") + return self.async_abort(reason="unknown") + if token_data["user"]["username"] != name: + return self.async_abort(reason="invalid_auth") + return await self._async_create_entry( + token_data, + import_info[CONF_API_KEY], + import_info[CONF_SHARED_SECRET], + ) diff --git a/homeassistant/components/remember_the_milk/const.py b/homeassistant/components/remember_the_milk/const.py index 2fccbf3ee5277..8109b6aa98ee7 100644 --- a/homeassistant/components/remember_the_milk/const.py +++ b/homeassistant/components/remember_the_milk/const.py @@ -2,4 +2,6 @@ import logging +CONF_SHARED_SECRET = "shared_secret" +DOMAIN = "remember_the_milk" LOGGER = logging.getLogger(__package__) diff --git a/homeassistant/components/remember_the_milk/entity.py b/homeassistant/components/remember_the_milk/entity.py index 174a69bca91a6..deac3b2f5ab71 100644 --- a/homeassistant/components/remember_the_milk/entity.py +++ b/homeassistant/components/remember_the_milk/entity.py @@ -2,10 +2,10 @@ from typing import override -from rtmapi import Rtm, RtmRequestFailedException +from aiortm import AioRTMClient, AioRTMError, AuthError from homeassistant.const import CONF_ID, CONF_NAME, STATE_OK -from homeassistant.core import ServiceCall +from homeassistant.core import ServiceCall, callback from homeassistant.helpers.entity import Entity from .const import LOGGER @@ -17,42 +17,21 @@ class RememberTheMilkEntity(Entity): def __init__( self, + *, name: str, - api_key: str, - shared_secret: str, - token: str, - rtm_config: RememberTheMilkConfiguration, + client: AioRTMClient, + config_entry_id: str, + storage: RememberTheMilkConfiguration, + token_valid: bool, ) -> None: """Create new instance of Remember The Milk component.""" self._name = name - self._api_key = api_key - self._shared_secret = shared_secret - self._token = token - self._rtm_config = rtm_config - self._rtm_api = Rtm(api_key, shared_secret, "delete", token) - self._token_valid = False - self._check_token() - LOGGER.debug("Instance created for account %s", self._name) + self._rtm_config = storage + self._client = client + self._config_entry_id = config_entry_id + self._token_valid = token_valid - def _check_token(self) -> bool: - """Check if the API token is still valid. - - If it is not valid any more, delete it from the configuration. This - will trigger a new authentication process. - """ - valid = self._rtm_api.token_valid() - if not valid: - LOGGER.error( - "Token for account %s is invalid. You need to register again!", - self.name, - ) - self._rtm_config.delete_token(self._name) - self._token_valid = False - else: - self._token_valid = True - return self._token_valid - - def create_task(self, call: ServiceCall) -> None: + async def create_task(self, call: ServiceCall) -> None: """Create a new task on Remember The Milk. You can use the smart syntax to define the attributes of a new task, @@ -60,31 +39,37 @@ def create_task(self, call: ServiceCall) -> None: due date to today. """ try: - task_name = call.data[CONF_NAME] - hass_id = call.data.get(CONF_ID) - rtm_id = None + task_name: str = call.data[CONF_NAME] + hass_id: str | None = call.data.get(CONF_ID) + rtm_id: tuple[int, int, int] | None = None if hass_id is not None: - rtm_id = self._rtm_config.get_rtm_id(self._name, hass_id) - result = self._rtm_api.rtm.timelines.create() - timeline = result.timeline.value + rtm_id = await self.hass.async_add_executor_job( + self._rtm_config.get_rtm_id, self._name, hass_id + ) + timeline_response = await self._client.rtm.timelines.create() + timeline = timeline_response.timeline if rtm_id is None: - result = self._rtm_api.rtm.tasks.add( - timeline=timeline, name=task_name, parse="1" + add_response = await self._client.rtm.tasks.add( + timeline=timeline, name=task_name, parse=True ) LOGGER.debug( "Created new task '%s' in account %s", task_name, self.name ) - if hass_id is not None: - self._rtm_config.set_rtm_id( - self._name, - hass_id, - result.list.id, - result.list.taskseries.id, - result.list.taskseries.task.id, - ) + if hass_id is None: + return + task_list = add_response.task_list + taskseries = task_list.taskseries[0] + await self.hass.async_add_executor_job( + self._rtm_config.set_rtm_id, + self._name, + hass_id, + task_list.id, + taskseries.id, + taskseries.task[0].id, + ) else: - self._rtm_api.rtm.tasks.setName( + await self._client.rtm.tasks.set_name( name=task_name, list_id=rtm_id[0], taskseries_id=rtm_id[1], @@ -97,17 +82,26 @@ def create_task(self, call: ServiceCall) -> None: self.name, task_name, ) - except RtmRequestFailedException as rtm_exception: + except AuthError as err: + LOGGER.error( + "Invalid authentication when creating task for account %s: %s", + self._name, + err, + ) + self._handle_token(False) + except AioRTMError as err: LOGGER.error( "Error creating new Remember The Milk task for account %s: %s", self._name, - rtm_exception, + err, ) - def complete_task(self, call: ServiceCall) -> None: + async def complete_task(self, call: ServiceCall) -> None: """Complete a task that was previously created by this component.""" hass_id = call.data[CONF_ID] - rtm_id = self._rtm_config.get_rtm_id(self._name, hass_id) + rtm_id = await self.hass.async_add_executor_job( + self._rtm_config.get_rtm_id, self._name, hass_id + ) if rtm_id is None: LOGGER.error( ( @@ -119,21 +113,32 @@ def complete_task(self, call: ServiceCall) -> None: ) return try: - result = self._rtm_api.rtm.timelines.create() - timeline = result.timeline.value - self._rtm_api.rtm.tasks.complete( + result = await self._client.rtm.timelines.create() + timeline = result.timeline + await self._client.rtm.tasks.complete( list_id=rtm_id[0], taskseries_id=rtm_id[1], task_id=rtm_id[2], timeline=timeline, ) - self._rtm_config.delete_rtm_id(self._name, hass_id) + await self.hass.async_add_executor_job( + self._rtm_config.delete_rtm_id, self._name, hass_id + ) LOGGER.debug("Completed task with id %s in account %s", hass_id, self._name) - except RtmRequestFailedException as rtm_exception: + except AuthError as err: LOGGER.error( - "Error creating new Remember The Milk task for account %s: %s", + "Invalid authentication when completing task with id %s for account %s: %s", + hass_id, self._name, - rtm_exception, + err, + ) + self._handle_token(False) + except AioRTMError as err: + LOGGER.error( + "Error completing task with id %s for account %s: %s", + hass_id, + self._name, + err, ) @property @@ -149,3 +154,11 @@ def state(self) -> str: if not self._token_valid: return "API token invalid" return STATE_OK + + @callback + def _handle_token(self, token_valid: bool) -> None: + self._token_valid = token_valid + self.async_write_ha_state() + self.hass.async_create_task( + self.hass.config_entries.async_reload(self._config_entry_id) + ) diff --git a/homeassistant/components/remember_the_milk/manifest.json b/homeassistant/components/remember_the_milk/manifest.json index 13c37d56dba0e..69add8e1eb44d 100644 --- a/homeassistant/components/remember_the_milk/manifest.json +++ b/homeassistant/components/remember_the_milk/manifest.json @@ -2,10 +2,11 @@ "domain": "remember_the_milk", "name": "Remember The Milk", "codeowners": [], - "dependencies": ["configurator"], + "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/remember_the_milk", + "integration_type": "service", "iot_class": "cloud_push", - "loggers": ["rtmapi"], + "loggers": ["aiortm"], "quality_scale": "legacy", - "requirements": ["RtmAPI==0.7.2", "httplib2==0.20.4"] + "requirements": ["aiortm==0.19.0"] } diff --git a/homeassistant/components/remember_the_milk/storage.py b/homeassistant/components/remember_the_milk/storage.py index 07b04c32b1cf9..0d7658403ccd8 100644 --- a/homeassistant/components/remember_the_milk/storage.py +++ b/homeassistant/components/remember_the_milk/storage.py @@ -1,8 +1,8 @@ -"""Store RTM configuration in Home Assistant storage.""" +"""Provide storage for Remember The Milk integration.""" import json from pathlib import Path -from typing import cast +from typing import Any, cast from homeassistant.const import CONF_TOKEN from homeassistant.core import HomeAssistant @@ -22,7 +22,10 @@ class RememberTheMilkConfiguration: def __init__(self, hass: HomeAssistant) -> None: """Create new instance of configuration.""" self._config_file_path = hass.config.path(CONFIG_FILE_NAME) - self._config = {} + self._config: dict[str, Any] = {} + + def setup(self) -> None: + """Set up the configuration.""" LOGGER.debug("Loading configuration from file: %s", self._config_file_path) try: self._config = json.loads( @@ -48,24 +51,8 @@ def _save_config(self) -> None: ) def get_token(self, profile_name: str) -> str | None: - """Get the server token for a profile.""" - if profile_name in self._config: - return cast(str, self._config[profile_name][CONF_TOKEN]) - return None - - def set_token(self, profile_name: str, token: str) -> None: - """Store a new server token for a profile.""" - self._initialize_profile(profile_name) - self._config[profile_name][CONF_TOKEN] = token - self._save_config() - - def delete_token(self, profile_name: str) -> None: - """Delete a token for a profile. - - Usually called when the token has expired. - """ - self._config.pop(profile_name, None) - self._save_config() + """Get the stored token for a profile, if any.""" + return cast("str | None", self._config.get(profile_name, {}).get(CONF_TOKEN)) def _initialize_profile(self, profile_name: str) -> None: """Initialize the data structures for a profile.""" @@ -76,7 +63,7 @@ def _initialize_profile(self, profile_name: str) -> None: def get_rtm_id( self, profile_name: str, hass_id: str - ) -> tuple[str, str, str] | None: + ) -> tuple[int, int, int] | None: """Get the RTM ids for a Home Assistant task ID. The id of a RTM tasks consists of the tuple: @@ -86,22 +73,28 @@ def get_rtm_id( ids = self._config[profile_name][CONF_ID_MAP].get(hass_id) if ids is None: return None - return ids[CONF_LIST_ID], ids[CONF_TIMESERIES_ID], ids[CONF_TASK_ID] + # Legacy storage stored the ids as strings, so convert to int. + return ( + int(ids[CONF_LIST_ID]), + int(ids[CONF_TIMESERIES_ID]), + int(ids[CONF_TASK_ID]), + ) def set_rtm_id( self, profile_name: str, hass_id: str, - list_id: str, - time_series_id: str, - rtm_task_id: str, + list_id: int, + time_series_id: int, + rtm_task_id: int, ) -> None: - """Add/Update the RTM task ID for a Home Assistant task IS.""" + """Add/Update the RTM task ID for a Home Assistant task ID.""" self._initialize_profile(profile_name) + # Store the ids as strings to keep the legacy storage format. id_tuple = { - CONF_LIST_ID: list_id, - CONF_TIMESERIES_ID: time_series_id, - CONF_TASK_ID: rtm_task_id, + CONF_LIST_ID: str(list_id), + CONF_TIMESERIES_ID: str(time_series_id), + CONF_TASK_ID: str(rtm_task_id), } self._config[profile_name][CONF_ID_MAP][hass_id] = id_tuple self._save_config() diff --git a/homeassistant/components/remember_the_milk/strings.json b/homeassistant/components/remember_the_milk/strings.json index c615e5b6b40ab..f50b2deb88845 100644 --- a/homeassistant/components/remember_the_milk/strings.json +++ b/homeassistant/components/remember_the_milk/strings.json @@ -1,4 +1,48 @@ { + "config": { + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_service%]", + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "timeout_token": "Timeout getting access token", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "step": { + "auth": { + "description": "Follow the link to authorize Home Assistant to access your Remember The Milk account. When done, click on the button below to continue.\n\n[Authorize]({url})" + }, + "user": { + "data": { + "api_key": "[%key:common::config_flow::data::api_key%]", + "shared_secret": "Shared secret" + }, + "data_description": { + "api_key": "The API key of your Remember The Milk API application.", + "shared_secret": "The shared secret of your Remember The Milk API application." + }, + "description": "Enter the API key and shared secret from a Remember The Milk API application. You can request these credentials using your Remember The Milk account." + } + } + }, + "issues": { + "deprecated_yaml_import_issue_cannot_connect": { + "description": "Configuring {integration_title} via YAML is deprecated and will be removed in a future release. While importing your configuration, a connection error occurred. Please restart Home Assistant to try again, or remove the {domain} configuration from your YAML and set the integration up via the UI.", + "title": "The {integration_title} YAML configuration is being removed" + }, + "deprecated_yaml_import_issue_invalid_auth": { + "description": "Configuring {integration_title} via YAML is deprecated and will be removed in a future release. While importing your configuration, a stored authentication token could not be found or was invalid. Please remove the {domain} configuration from your YAML and set the integration up via the UI.", + "title": "The {integration_title} YAML configuration is being removed" + }, + "deprecated_yaml_import_issue_unknown": { + "description": "Configuring {integration_title} via YAML is deprecated and will be removed in a future release. While importing your configuration, an unknown error occurred. Please remove the {domain} configuration from your YAML and set the integration up via the UI.", + "title": "The {integration_title} YAML configuration is being removed" + } + }, "services": { "complete_task": { "description": "Completes a task that was previously created.", diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 9d191fd34776a..864f89f265faa 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -650,6 +650,7 @@ "redgtech", "refoss", "rehlko", + "remember_the_milk", "remote_calendar", "renault", "renson", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 5f3f8ae39a70d..fff661a17fb32 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -5982,8 +5982,8 @@ }, "remember_the_milk": { "name": "Remember The Milk", - "integration_type": "hub", - "config_flow": false, + "integration_type": "service", + "config_flow": true, "iot_class": "cloud_push" }, "remote_calendar": { diff --git a/requirements_all.txt b/requirements_all.txt index a8193ac933d9c..17194718ce2d9 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -110,9 +110,6 @@ RachioPy==1.1.0 # homeassistant.components.python_script RestrictedPython==8.5 -# homeassistant.components.remember_the_milk -RtmAPI==0.7.2 - # homeassistant.components.recorder # homeassistant.components.sql SQLAlchemy==2.0.52 @@ -409,6 +406,9 @@ aiorecollect==2023.09.0 # homeassistant.components.ridwell aioridwell==2025.09.0 +# homeassistant.components.remember_the_milk +aiortm==0.19.0 + # homeassistant.components.ruckus_unleashed aioruckus==0.46.3 @@ -1304,9 +1304,6 @@ homevolt==0.5.0 # homeassistant.components.horizon horimote==0.4.1 -# homeassistant.components.remember_the_milk -httplib2==0.20.4 - # homeassistant.components.huawei_lte huawei-lte-api==1.11.0 diff --git a/tests/components/remember_the_milk/conftest.py b/tests/components/remember_the_milk/conftest.py index ac80cf2972bf0..cd667cfd34ca4 100644 --- a/tests/components/remember_the_milk/conftest.py +++ b/tests/components/remember_the_milk/conftest.py @@ -1,37 +1,71 @@ """Provide common pytest fixtures.""" from collections.abc import AsyncGenerator, Generator -from unittest.mock import MagicMock, patch +from unittest.mock import AsyncMock, MagicMock, patch import pytest +from homeassistant.components.remember_the_milk.const import DOMAIN from homeassistant.core import HomeAssistant -from .const import TOKEN +from .const import CREATE_ENTRY_DATA, PROFILE, TOKEN_RESPONSE + +from tests.common import MockConfigEntry + + +@pytest.fixture +def ignore_missing_translations(request: pytest.FixtureRequest) -> list[str]: + """Ignore translations for the per-account services registered at runtime. + + The services are only registered when the integration is set up, so only + ignore them for the test modules that load the integration. + """ + if request.module.__name__.endswith((".test_entity", ".test_init")): + return [ + f"component.{DOMAIN}.services.{PROFILE}_create_task.", + f"component.{DOMAIN}.services.{PROFILE}_complete_task.", + ] + return [] @pytest.fixture(name="client") def client_fixture() -> Generator[MagicMock]: """Create a mock client.""" - client = MagicMock() with ( patch( - "homeassistant.components.remember_the_milk.entity.Rtm" - ) as entity_client_class, - patch("homeassistant.components.remember_the_milk.Rtm") as client_class, + "homeassistant.components.remember_the_milk.AioRTMClient", + ) as client_class, + patch( + "homeassistant.components.remember_the_milk.config_flow.Auth.check_token", + AsyncMock(return_value=TOKEN_RESPONSE), + ), + patch( + "homeassistant.components.remember_the_milk.config_flow.Auth.authenticate_desktop", + AsyncMock(return_value=("https://test-url.com", "test-frob")), + ), + patch( + "homeassistant.components.remember_the_milk.config_flow.Auth.get_token", + AsyncMock(return_value=TOKEN_RESPONSE), + ), ): - entity_client_class.return_value = client - client_class.return_value = client - client.token = TOKEN - client.token_valid.return_value = True + client = client_class.return_value + client.rtm.api.check_token = AsyncMock(return_value=TOKEN_RESPONSE) timelines = MagicMock() - timelines.timeline.value = "1234" - client.rtm.timelines.create.return_value = timelines - add_response = MagicMock() - add_response.list.id = "1" - add_response.list.taskseries.id = "2" - add_response.list.taskseries.task.id = "3" - client.rtm.tasks.add.return_value = add_response + timelines.timeline = 1234 + client.rtm.timelines.create = AsyncMock(return_value=timelines) + response = MagicMock() + response.task_list.id = 1 + response.task_list.taskseries = [] + task_series = MagicMock() + task_series.id = 2 + task_series.task = [] + task = MagicMock() + task.id = 3 + task_series.task.append(task) + response.task_list.taskseries.append(task_series) + client.rtm.tasks.add = AsyncMock(return_value=response) + client.rtm.tasks.complete = AsyncMock(return_value=response) + client.rtm.tasks.set_name = AsyncMock(return_value=response) yield client @@ -43,6 +77,28 @@ async def storage(hass: HomeAssistant, client) -> AsyncGenerator[MagicMock]: "homeassistant.components.remember_the_milk.RememberTheMilkConfiguration" ) as storage_class: storage = storage_class.return_value - storage.get_token.return_value = TOKEN storage.get_rtm_id.return_value = None + storage.get_token.return_value = "test-token" yield storage + + +@pytest.fixture +def config_entry(hass: HomeAssistant) -> MockConfigEntry: + """Return a mock config entry.""" + entry = MockConfigEntry( + data=CREATE_ENTRY_DATA, + domain=DOMAIN, + unique_id="1234567", + ) + entry.add_to_hass(hass) + return entry + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.remember_the_milk.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + yield mock_setup_entry diff --git a/tests/components/remember_the_milk/const.py b/tests/components/remember_the_milk/const.py index bed39eec5f850..f641024ce392e 100644 --- a/tests/components/remember_the_milk/const.py +++ b/tests/components/remember_the_milk/const.py @@ -3,17 +3,33 @@ import json PROFILE = "myprofile" -CONFIG = { - "name": f"{PROFILE}", +CREATE_ENTRY_DATA = { "api_key": "test-api-key", - "shared_secret": "test-shared-secret", + "shared_secret": "test-secret", + "token": "test-token", + "username": PROFILE, } -TOKEN = "mytoken" -JSON_STRING = json.dumps( +TOKEN_RESPONSE = { + "token": "test-token", + "perms": "delete", + "user": {"id": "1234567", "username": PROFILE, "fullname": "John Smith"}, +} + +# The legacy configuration file format: +LEGACY_JSON_STRING = json.dumps( { - "myprofile": { + PROFILE: { "token": "mytoken", "id_map": {"123": {"list_id": "1", "timeseries_id": "2", "task_id": "3"}}, } } ) + +# The new configuration file format: +JSON_STRING = json.dumps( + { + PROFILE: { + "id_map": {"123": {"list_id": "1", "timeseries_id": "2", "task_id": "3"}}, + } + } +) diff --git a/tests/components/remember_the_milk/test_config_flow.py b/tests/components/remember_the_milk/test_config_flow.py new file mode 100644 index 0000000000000..91af9c6003729 --- /dev/null +++ b/tests/components/remember_the_milk/test_config_flow.py @@ -0,0 +1,270 @@ +"""Test the Remember The Milk config flow.""" + +import asyncio +from collections.abc import Awaitable +from typing import Any +from unittest.mock import AsyncMock, patch + +from aiortm import AioRTMError, AuthError +import pytest + +from homeassistant import config_entries +from homeassistant.components.remember_the_milk.config_flow import TOKEN_TIMEOUT_SEC +from homeassistant.components.remember_the_milk.const import DOMAIN +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from .const import CREATE_ENTRY_DATA, PROFILE, TOKEN_RESPONSE + +from tests.common import MockConfigEntry + +pytestmark = pytest.mark.usefixtures("mock_setup_entry") + + +async def test_successful_flow( + hass: HomeAssistant, client: AsyncMock, mock_setup_entry: AsyncMock +) -> None: + """Test successful flow.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert not result["errors"] + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "api_key": "test-api-key", + "shared_secret": "test-secret", + }, + ) + + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == TOKEN_RESPONSE["user"]["fullname"] + assert result["data"] == CREATE_ENTRY_DATA + assert result["result"].unique_id == TOKEN_RESPONSE["user"]["id"] + assert len(mock_setup_entry.mock_calls) == 1 + + +@pytest.mark.parametrize( + ("exception", "error"), + [ + (AuthError, "invalid_auth"), + (AioRTMError, "cannot_connect"), + (Exception, "unknown"), + ], +) +async def test_form_errors( + hass: HomeAssistant, + client: AsyncMock, + mock_setup_entry: AsyncMock, + exception: Exception, + error: str, +) -> None: + """Test form errors when getting the authentication URL.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + with patch( + "homeassistant.components.remember_the_milk.config_flow.Auth.authenticate_desktop", + side_effect=exception, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "api_key": "test-api-key", + "shared_secret": "test-secret", + }, + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": error} + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "api_key": "test-api-key", + "shared_secret": "test-secret", + }, + ) + + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == TOKEN_RESPONSE["user"]["fullname"] + assert result["data"] == CREATE_ENTRY_DATA + assert result["result"].unique_id == TOKEN_RESPONSE["user"]["id"] + assert len(mock_setup_entry.mock_calls) == 1 + + +async def mock_get_token(*args: Any) -> None: + """Handle get token.""" + await asyncio.Future() + + +@pytest.mark.parametrize( + ("side_effect", "reason", "timeout"), + [ + (AuthError, "invalid_auth", TOKEN_TIMEOUT_SEC), + (AioRTMError, "cannot_connect", TOKEN_TIMEOUT_SEC), + (Exception, "unknown", TOKEN_TIMEOUT_SEC), + (mock_get_token, "timeout_token", 0), + ], +) +async def test_token_abort_reasons( + hass: HomeAssistant, + client: AsyncMock, + side_effect: Exception | Awaitable[None], + reason: str, + timeout: int, +) -> None: + """Test abort result when getting token.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "api_key": "test-api-key", + "shared_secret": "test-secret", + }, + ) + + with ( + patch( + "homeassistant.components.remember_the_milk.config_flow.Auth.get_token", + side_effect=side_effect, + ), + patch( + "homeassistant.components.remember_the_milk.config_flow.TOKEN_TIMEOUT_SEC", + timeout, + ), + ): + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == reason + + +async def test_abort_if_already_configured( + hass: HomeAssistant, client: AsyncMock, config_entry: MockConfigEntry +) -> None: + """Test abort if the same username is already configured.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert not result["errors"] + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "api_key": "test-api-key", + "shared_secret": "test-secret", + }, + ) + + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +async def test_import_flow( + hass: HomeAssistant, client: AsyncMock, mock_setup_entry: AsyncMock +) -> None: + """Test import flow with a valid stored token.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_IMPORT}, + data={ + "api_key": "test-api-key", + "shared_secret": "test-secret", + "name": PROFILE, + "token": "test-token", + }, + ) + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == TOKEN_RESPONSE["user"]["fullname"] + assert result["data"] == { + "api_key": "test-api-key", + "shared_secret": "test-secret", + "token": "test-token", + "username": PROFILE, + } + assert result["result"].unique_id == TOKEN_RESPONSE["user"]["id"] + assert len(mock_setup_entry.mock_calls) == 1 + + +@pytest.mark.parametrize( + ("token", "side_effect", "reason"), + [ + (None, None, "invalid_auth"), + ("test-token", AuthError, "invalid_auth"), + ("test-token", AioRTMError, "cannot_connect"), + ("test-token", Exception, "unknown"), + ], +) +async def test_import_flow_abort( + hass: HomeAssistant, + token: str | None, + side_effect: type[Exception] | None, + reason: str, +) -> None: + """Test import flow aborts without a valid token.""" + with patch( + "homeassistant.components.remember_the_milk.config_flow.Auth.check_token", + side_effect=side_effect, + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_IMPORT}, + data={ + "api_key": "test-api-key", + "shared_secret": "test-secret", + "name": "test-name", + "token": token, + }, + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == reason + + +async def test_import_flow_username_mismatch( + hass: HomeAssistant, client: AsyncMock +) -> None: + """Test import flow aborts when the token username doesn't match the name.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_IMPORT}, + data={ + "api_key": "test-api-key", + "shared_secret": "test-secret", + "name": "other-name", + "token": "test-token", + }, + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "invalid_auth" + + +async def test_import_flow_already_configured( + hass: HomeAssistant, client: AsyncMock, config_entry: MockConfigEntry +) -> None: + """Test import flow aborts when the account name is already configured.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_IMPORT}, + data={ + "api_key": "test-api-key", + "shared_secret": "test-secret", + "name": PROFILE, + "token": "test-token", + }, + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" diff --git a/tests/components/remember_the_milk/test_entity.py b/tests/components/remember_the_milk/test_entity.py index bdd4189e394d3..96f158aed786a 100644 --- a/tests/components/remember_the_milk/test_entity.py +++ b/tests/components/remember_the_milk/test_entity.py @@ -3,29 +3,40 @@ from typing import Any from unittest.mock import MagicMock, call +from aiortm import AioRTMError, AuthError import pytest -from rtmapi import RtmRequestFailedException from homeassistant.components.remember_the_milk import DOMAIN +from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component -from .const import CONFIG, PROFILE +from .const import PROFILE +from tests.common import MockConfigEntry +CONFIG = { + "name": f"{PROFILE}", + "api_key": "test-api-key", + "shared_secret": "test-shared-secret", +} + + +@pytest.mark.usefixtures("storage") @pytest.mark.parametrize( - ("valid_token", "entity_state"), [(True, "ok"), (False, "API token invalid")] + ("check_token_side_effect", "entity_state"), + [(None, "ok"), (AuthError("Invalid token!"), "API token invalid")], ) async def test_entity_state( hass: HomeAssistant, client: MagicMock, - storage: MagicMock, - valid_token: bool, + config_entry: MockConfigEntry, + check_token_side_effect: Exception | None, entity_state: str, ) -> None: """Test the entity state.""" - client.token_valid.return_value = valid_token - assert await async_setup_component(hass, DOMAIN, {DOMAIN: CONFIG}) + client.rtm.api.check_token.side_effect = check_token_side_effect + await hass.config_entries.async_setup(config_entry.entry_id) entity_id = f"{DOMAIN}.{PROFILE}" state = hass.states.get(entity_id) @@ -50,7 +61,7 @@ async def test_entity_state( ), [ ( - ("1", "2", "3"), + (1, 2, 3), f"{PROFILE}_create_task", {"name": "Test 1"}, 0, @@ -59,9 +70,9 @@ async def test_entity_state( "rtm.tasks.add", 1, call( - timeline="1234", + timeline=1234, name="Test 1", - parse="1", + parse=True, ), "set_rtm_id", 0, @@ -77,36 +88,36 @@ async def test_entity_state( "rtm.tasks.add", 1, call( - timeline="1234", + timeline=1234, name="Test 1", - parse="1", + parse=True, ), "set_rtm_id", 1, - call(PROFILE, "test_1", "1", "2", "3"), + call(PROFILE, "test_1", 1, 2, 3), ), ( - ("1", "2", "3"), + (1, 2, 3), f"{PROFILE}_create_task", {"name": "Test 1", "id": "test_1"}, 1, call(PROFILE, "test_1"), 1, - "rtm.tasks.setName", + "rtm.tasks.set_name", 1, call( name="Test 1", - list_id="1", - taskseries_id="2", - task_id="3", - timeline="1234", + list_id=1, + taskseries_id=2, + task_id=3, + timeline=1234, ), "set_rtm_id", 0, None, ), ( - ("1", "2", "3"), + (1, 2, 3), f"{PROFILE}_complete_task", {"id": "test_1"}, 1, @@ -115,10 +126,10 @@ async def test_entity_state( "rtm.tasks.complete", 1, call( - list_id="1", - taskseries_id="2", - task_id="3", - timeline="1234", + list_id=1, + taskseries_id=2, + task_id=3, + timeline=1234, ), "delete_rtm_id", 1, @@ -173,52 +184,52 @@ async def test_services( ), [ ( - ("1", "2", "3"), + (1, 2, 3), f"{PROFILE}_create_task", {"name": "Test 1"}, "rtm.timelines.create", - RtmRequestFailedException("rtm.timelines.create", "400", "Bad request"), - "Request rtm.timelines.create failed. Status: 400, reason: Bad request.", + AioRTMError("Boom!"), + "Error creating new Remember The Milk task for account myprofile: Boom!", ), ( - ("1", "2", "3"), + (1, 2, 3), f"{PROFILE}_create_task", {"name": "Test 1"}, "rtm.tasks.add", - RtmRequestFailedException("rtm.tasks.add", "400", "Bad request"), - "Request rtm.tasks.add failed. Status: 400, reason: Bad request.", + AioRTMError("Boom!"), + "Error creating new Remember The Milk task for account myprofile: Boom!", ), ( None, f"{PROFILE}_create_task", {"name": "Test 1", "id": "test_1"}, "rtm.timelines.create", - RtmRequestFailedException("rtm.timelines.create", "400", "Bad request"), - "Request rtm.timelines.create failed. Status: 400, reason: Bad request.", + AioRTMError("Boom!"), + "Error creating new Remember The Milk task for account myprofile: Boom!", ), ( None, f"{PROFILE}_create_task", {"name": "Test 1", "id": "test_1"}, "rtm.tasks.add", - RtmRequestFailedException("rtm.tasks.add", "400", "Bad request"), - "Request rtm.tasks.add failed. Status: 400, reason: Bad request.", + AioRTMError("Boom!"), + "Error creating new Remember The Milk task for account myprofile: Boom!", ), ( - ("1", "2", "3"), + (1, 2, 3), f"{PROFILE}_create_task", {"name": "Test 1", "id": "test_1"}, "rtm.timelines.create", - RtmRequestFailedException("rtm.timelines.create", "400", "Bad request"), - "Request rtm.timelines.create failed. Status: 400, reason: Bad request.", + AioRTMError("Boom!"), + "Error creating new Remember The Milk task for account myprofile: Boom!", ), ( - ("1", "2", "3"), + (1, 2, 3), f"{PROFILE}_create_task", {"name": "Test 1", "id": "test_1"}, - "rtm.tasks.setName", - RtmRequestFailedException("rtm.tasks.setName", "400", "Bad request"), - "Request rtm.tasks.setName failed. Status: 400, reason: Bad request.", + "rtm.tasks.set_name", + AioRTMError("Boom!"), + "Error creating new Remember The Milk task for account myprofile: Boom!", ), ( None, @@ -232,20 +243,20 @@ async def test_services( ), ), ( - ("1", "2", "3"), + (1, 2, 3), f"{PROFILE}_complete_task", {"id": "test_1"}, "rtm.timelines.create", - RtmRequestFailedException("rtm.timelines.create", "400", "Bad request"), - "Request rtm.timelines.create failed. Status: 400, reason: Bad request.", + AioRTMError("Boom!"), + "Error completing task with id test_1 for account myprofile: Boom!", ), ( - ("1", "2", "3"), + (1, 2, 3), f"{PROFILE}_complete_task", {"id": "test_1"}, "rtm.tasks.complete", - RtmRequestFailedException("rtm.tasks.complete", "400", "Bad request"), - "Request rtm.tasks.complete failed. Status: 400, reason: Bad request.", + AioRTMError("Boom!"), + "Error completing task with id test_1 for account myprofile: Boom!", ), ], ) @@ -274,3 +285,74 @@ async def test_services_errors( await hass.services.async_call(DOMAIN, service, service_data, blocking=True) assert error_message in caplog.text + + +@pytest.mark.parametrize( + ( + "get_rtm_id_return_value", + "service", + "service_data", + "method", + "error_message", + ), + [ + ( + (1, 2, 3), + f"{PROFILE}_create_task", + {"name": "Test 1"}, + "rtm.timelines.create", + "Invalid authentication when creating task for account myprofile: Boom!", + ), + ( + (1, 2, 3), + f"{PROFILE}_create_task", + {"name": "Test 1", "id": "test_1"}, + "rtm.tasks.set_name", + "Invalid authentication when creating task for account myprofile: Boom!", + ), + ( + (1, 2, 3), + f"{PROFILE}_complete_task", + {"id": "test_1"}, + "rtm.tasks.complete", + ( + "Invalid authentication when completing task with id test_1 " + "for account myprofile: Boom!" + ), + ), + ], +) +async def test_services_auth_errors( + hass: HomeAssistant, + client: MagicMock, + storage: MagicMock, + caplog: pytest.LogCaptureFixture, + get_rtm_id_return_value: Any, + service: str, + service_data: dict[str, Any], + method: str, + error_message: str, +) -> None: + """Test that an auth error invalidates the token and reloads the entry.""" + assert await async_setup_component(hass, DOMAIN, {DOMAIN: CONFIG}) + storage.get_rtm_id.return_value = get_rtm_id_return_value + + entry = hass.config_entries.async_entries(DOMAIN)[0] + assert entry.state is ConfigEntryState.LOADED + state = hass.states.get(f"{DOMAIN}.{PROFILE}") + assert state + assert state.state == "ok" + + client_method = client + for name in method.split("."): + client_method = getattr(client_method, name) + + client_method.side_effect = AuthError("Boom!") + # The token is now invalid, so re-checking it during the reload fails too. + client.rtm.api.check_token.side_effect = AuthError("Invalid token!") + + await hass.services.async_call(DOMAIN, service, service_data, blocking=True) + await hass.async_block_till_done() + + assert error_message in caplog.text + assert entry.state is ConfigEntryState.SETUP_ERROR diff --git a/tests/components/remember_the_milk/test_init.py b/tests/components/remember_the_milk/test_init.py index e3cc2dbdd88c3..89f41b0874420 100644 --- a/tests/components/remember_the_milk/test_init.py +++ b/tests/components/remember_the_milk/test_init.py @@ -1,68 +1,115 @@ """Test the Remember The Milk integration.""" -from collections.abc import Generator -from unittest.mock import MagicMock, patch +from unittest.mock import MagicMock +from aiortm import AioRTMError, AuthError import pytest -from homeassistant.components.remember_the_milk import DOMAIN -from homeassistant.core import HomeAssistant +from homeassistant.components.remember_the_milk.const import DOMAIN +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant +from homeassistant.helpers import issue_registry as ir from homeassistant.setup import async_setup_component -from .const import CONFIG, PROFILE, TOKEN +from .const import PROFILE +from tests.common import MockConfigEntry -@pytest.fixture(autouse=True) -def configure_id() -> Generator[str]: - """Fixture to return a configure_id.""" - mock_id = "1-1" - with patch( - "homeassistant.components.configurator.Configurator._generate_unique_id" - ) as generate_id: - generate_id.return_value = mock_id - yield mock_id +CONFIG = { + "name": "myprofile", + "api_key": "test-api-key", + "shared_secret": "test-shared-secret", +} +@pytest.mark.usefixtures("storage") +async def test_load_unload_config_entry( + hass: HomeAssistant, + client: MagicMock, + config_entry: MockConfigEntry, +) -> None: + """Test loading and unloading a config entry.""" + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state is ConfigEntryState.LOADED + + assert await hass.config_entries.async_unload(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state is ConfigEntryState.NOT_LOADED + + +@pytest.mark.usefixtures("storage") @pytest.mark.parametrize( - ("token", "rtm_entity_exists", "configurator_end_state"), - [(TOKEN, True, "configured"), (None, False, "configure")], -) -@pytest.mark.parametrize( - "ignore_missing_translations", ["component.configurator.services.configure."] + ("side_effect", "entry_state", "ignore_missing_translations"), + [ + pytest.param( + AuthError("Invalid token!"), + ConfigEntryState.SETUP_ERROR, + [ + f"component.{DOMAIN}.services.{PROFILE}_create_task.", + f"component.{DOMAIN}.services.{PROFILE}_complete_task.", + ], + id="auth_error", + ), + pytest.param( + AioRTMError("Connection failed!"), + ConfigEntryState.SETUP_RETRY, + [], + id="rtm_error", + ), + ], ) -async def test_configurator( +async def test_config_entry_check_token_fails( hass: HomeAssistant, client: MagicMock, - storage: MagicMock, - configure_id: str, - token: str | None, - rtm_entity_exists: bool, - configurator_end_state: str, + config_entry: MockConfigEntry, + side_effect: Exception, + entry_state: ConfigEntryState, ) -> None: - """Test configurator.""" - storage.get_token.return_value = None - client.authenticate_desktop.return_value = ("test-url", "test-frob") - client.token = token - rtm_entity_id = f"{DOMAIN}.{PROFILE}" - configure_entity_id = f"configurator.{DOMAIN}_{PROFILE}" + """Test that token check failures put the entry in the expected state.""" + client.rtm.api.check_token.side_effect = side_effect - assert await async_setup_component(hass, DOMAIN, {DOMAIN: CONFIG}) + await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() - assert hass.states.get(rtm_entity_id) is None - state = hass.states.get(configure_entity_id) - assert state - assert state.state == "configure" + assert config_entry.state is entry_state + - await hass.services.async_call( - "configurator", - "configure", - {"configure_id": configure_id}, - blocking=True, +@pytest.mark.usefixtures("client", "storage") +async def test_import_creates_deprecation_issue( + hass: HomeAssistant, + issue_registry: ir.IssueRegistry, +) -> None: + """Test a successful YAML import creates a deprecation repair issue.""" + assert await async_setup_component(hass, DOMAIN, {DOMAIN: CONFIG}) + await hass.async_block_till_done() + + assert len(hass.config_entries.async_entries(DOMAIN)) == 1 + assert issue_registry.async_get_issue( + HOMEASSISTANT_DOMAIN, f"deprecated_yaml_{DOMAIN}" ) + + +@pytest.mark.parametrize("ignore_missing_translations", [[]]) +@pytest.mark.usefixtures("client") +async def test_import_without_token_creates_issue( + hass: HomeAssistant, + storage: MagicMock, + issue_registry: ir.IssueRegistry, +) -> None: + """Test YAML import without a stored token aborts and creates an issue. + + Without a token the import can't be completed, so no config entry is + created and the user is guided to set the integration up via the UI. + """ + storage.get_token.return_value = None + + assert await async_setup_component(hass, DOMAIN, {DOMAIN: CONFIG}) await hass.async_block_till_done() - assert bool(hass.states.get(rtm_entity_id)) == rtm_entity_exists - state = hass.states.get(configure_entity_id) - assert state - assert state.state == configurator_end_state + assert not hass.config_entries.async_entries(DOMAIN) + assert issue_registry.async_get_issue( + DOMAIN, "deprecated_yaml_import_issue_invalid_auth" + ) diff --git a/tests/components/remember_the_milk/test_storage.py b/tests/components/remember_the_milk/test_storage.py index 6ae774a3d0d3a..e872fe657e57e 100644 --- a/tests/components/remember_the_milk/test_storage.py +++ b/tests/components/remember_the_milk/test_storage.py @@ -8,51 +8,52 @@ from homeassistant.components import remember_the_milk as rtm from homeassistant.core import HomeAssistant -from .const import JSON_STRING, PROFILE, TOKEN +from .const import JSON_STRING, LEGACY_JSON_STRING, PROFILE -def test_set_get_delete_token(hass: HomeAssistant) -> None: - """Test set, get and delete token.""" - open_mock = mock_open() - with patch( - "homeassistant.components.remember_the_milk.storage.Path.open", open_mock - ): - config = rtm.RememberTheMilkConfiguration(hass) - assert open_mock.return_value.write.call_count == 0 - assert config.get_token(PROFILE) is None - assert open_mock.return_value.write.call_count == 0 - config.set_token(PROFILE, TOKEN) - assert open_mock.return_value.write.call_count == 1 - assert open_mock.return_value.write.call_args[0][0] == json.dumps( - { - "myprofile": { - "id_map": {}, - "token": "mytoken", - } - } - ) - assert config.get_token(PROFILE) == TOKEN - assert open_mock.return_value.write.call_count == 1 - config.delete_token(PROFILE) - assert open_mock.return_value.write.call_count == 2 - assert open_mock.return_value.write.call_args[0][0] == json.dumps({}) - assert config.get_token(PROFILE) is None - assert open_mock.return_value.write.call_count == 2 - +@pytest.mark.parametrize( + "json_string", + [JSON_STRING, LEGACY_JSON_STRING], + ids=["new_format", "legacy_format"], +) +def test_config_load(hass: HomeAssistant, json_string: str) -> None: + """Test loading from the file. -def test_config_load(hass: HomeAssistant) -> None: - """Test loading from the file.""" + The legacy configuration file format stored the ids as strings, so + check that the ids are always returned as integers. + """ + config = rtm.RememberTheMilkConfiguration(hass) with ( patch( "homeassistant.components.remember_the_milk.storage.Path.open", - mock_open(read_data=JSON_STRING), + mock_open(read_data=json_string), ), ): - config = rtm.RememberTheMilkConfiguration(hass) + config.setup() rtm_id = config.get_rtm_id(PROFILE, "123") assert rtm_id is not None - assert rtm_id == ("1", "2", "3") + assert rtm_id == (1, 2, 3) + + +@pytest.mark.parametrize( + ("json_string", "expected_token"), + [(LEGACY_JSON_STRING, "mytoken"), (JSON_STRING, None)], + ids=["legacy_format", "new_format"], +) +def test_get_token( + hass: HomeAssistant, json_string: str, expected_token: str | None +) -> None: + """Test getting the stored token for a profile.""" + config = rtm.RememberTheMilkConfiguration(hass) + with patch( + "homeassistant.components.remember_the_milk.storage.Path.open", + mock_open(read_data=json_string), + ): + config.setup() + + assert config.get_token(PROFILE) == expected_token + assert config.get_token("unknown-profile") is None @pytest.mark.parametrize( @@ -67,7 +68,7 @@ def test_config_load_file_error(hass: HomeAssistant, side_effect: Exception) -> side_effect=side_effect, ), ): - config = rtm.RememberTheMilkConfiguration(hass) + config.setup() # The config should be empty and we should not have any errors # when trying to access it. @@ -84,7 +85,7 @@ def test_config_load_invalid_data(hass: HomeAssistant) -> None: mock_open(read_data="random characters"), ), ): - config = rtm.RememberTheMilkConfiguration(hass) + config.setup() # The config should be empty and we should not have any errors # when trying to access it. @@ -95,15 +96,15 @@ def test_config_load_invalid_data(hass: HomeAssistant) -> None: def test_config_set_delete_id(hass: HomeAssistant) -> None: """Test setting and deleting an id from the config.""" hass_id = "123" - list_id = "1" - timeseries_id = "2" - rtm_id = "3" + list_id = 1 + timeseries_id = 2 + rtm_id = 3 open_mock = mock_open() config = rtm.RememberTheMilkConfiguration(hass) with patch( "homeassistant.components.remember_the_milk.storage.Path.open", open_mock ): - config = rtm.RememberTheMilkConfiguration(hass) + config.setup() assert open_mock.return_value.write.call_count == 0 assert config.get_rtm_id(PROFILE, hass_id) is None assert open_mock.return_value.write.call_count == 0 @@ -114,7 +115,11 @@ def test_config_set_delete_id(hass: HomeAssistant) -> None: { "myprofile": { "id_map": { - "123": {"list_id": "1", "timeseries_id": "2", "task_id": "3"} + "123": { + "list_id": "1", + "timeseries_id": "2", + "task_id": "3", + } } } }