From 49dec1577e3d0b4e1e2bd58ab5422ee127d65d9b Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Thu, 3 Oct 2024 21:38:04 +0200 Subject: [PATCH 01/10] Use reauth helpers in elmax config flow (#127417) --- homeassistant/components/elmax/config_flow.py | 20 ++++++++----------- 1 file changed, 8 insertions(+), 12 deletions(-) diff --git a/homeassistant/components/elmax/config_flow.py b/homeassistant/components/elmax/config_flow.py index 69f69a5fd313d..bf479e997efba 100644 --- a/homeassistant/components/elmax/config_flow.py +++ b/homeassistant/components/elmax/config_flow.py @@ -13,7 +13,7 @@ import voluptuous as vol from homeassistant.components.zeroconf import ZeroconfServiceInfo -from homeassistant.config_entries import ConfigEntry, ConfigFlow, ConfigFlowResult +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.exceptions import HomeAssistantError from .common import ( @@ -114,7 +114,6 @@ class ElmaxConfigFlow(ConfigFlow, domain=DOMAIN): # Panel selection variables _panels_schema: vol.Schema _panel_names: dict - _entry: ConfigEntry | None async def async_step_user( self, user_input: dict[str, Any] | None = None @@ -395,7 +394,6 @@ async def async_step_reauth( self, entry_data: Mapping[str, Any] ) -> ConfigFlowResult: """Perform reauth upon an API authentication error.""" - self._entry = self.hass.config_entries.async_get_entry(self.context["entry_id"]) self._reauth_cloud_username = entry_data.get(CONF_ELMAX_USERNAME) self._reauth_cloud_panelid = entry_data.get(CONF_ELMAX_PANEL_ID) return await self.async_step_reauth_confirm() @@ -413,7 +411,7 @@ async def async_step_reauth_confirm( # Handle authentication, make sure the panel we are re-authenticating against is listed among results # and verify its pin is correct. - assert self._entry is not None + reauth_entry = self._get_reauth_entry() try: # Test login. client = await self._async_login(username=username, password=password) @@ -421,14 +419,14 @@ async def async_step_reauth_confirm( panels = [ p for p in await client.list_control_panels() - if p.hash == self._entry.data[CONF_ELMAX_PANEL_ID] + if p.hash == reauth_entry.data[CONF_ELMAX_PANEL_ID] ] if len(panels) < 1: raise NoOnlinePanelsError # noqa: TRY301 # Verify the pin is still valid. await client.get_panel_status( - control_panel_id=self._entry.data[CONF_ELMAX_PANEL_ID], + control_panel_id=reauth_entry.data[CONF_ELMAX_PANEL_ID], pin=panel_pin, ) @@ -440,18 +438,16 @@ async def async_step_reauth_confirm( errors["base"] = "invalid_pin" # If all went right, update the config entry - if not errors: - self.hass.config_entries.async_update_entry( - self._entry, + else: + return self.async_update_reload_and_abort( + reauth_entry, data={ - CONF_ELMAX_PANEL_ID: self._entry.data[CONF_ELMAX_PANEL_ID], + CONF_ELMAX_PANEL_ID: reauth_entry.data[CONF_ELMAX_PANEL_ID], CONF_ELMAX_PANEL_PIN: panel_pin, CONF_ELMAX_USERNAME: username, CONF_ELMAX_PASSWORD: password, }, ) - await self.hass.config_entries.async_reload(self._entry.entry_id) - return self.async_abort(reason="reauth_successful") # Otherwise start over and show the relative error message return self.async_show_form( From 0ae0047246b815f721a33f448ac7c5a5f7132418 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Thu, 3 Oct 2024 21:39:39 +0200 Subject: [PATCH 02/10] Fix config entry unique_id collision in lamarzocco tests (#127484) --- tests/components/lamarzocco/conftest.py | 34 +++++++++++-------- .../lamarzocco/test_binary_sensor.py | 6 ++-- tests/components/lamarzocco/test_sensor.py | 5 ++- 3 files changed, 24 insertions(+), 21 deletions(-) diff --git a/tests/components/lamarzocco/conftest.py b/tests/components/lamarzocco/conftest.py index 1a4fbbd4a0c5e..2520433e86ab6 100644 --- a/tests/components/lamarzocco/conftest.py +++ b/tests/components/lamarzocco/conftest.py @@ -24,7 +24,7 @@ def mock_config_entry( hass: HomeAssistant, mock_lamarzocco: MagicMock ) -> MockConfigEntry: """Return the default mocked config entry.""" - entry = MockConfigEntry( + return MockConfigEntry( title="My LaMarzocco", domain=DOMAIN, version=2, @@ -37,8 +37,25 @@ def mock_config_entry( }, unique_id=mock_lamarzocco.serial_number, ) - entry.add_to_hass(hass) - return entry + + +@pytest.fixture +def mock_config_entry_no_local_connection( + hass: HomeAssistant, mock_lamarzocco: MagicMock +) -> MockConfigEntry: + """Return the default mocked config entry.""" + return MockConfigEntry( + title="My LaMarzocco", + domain=DOMAIN, + version=2, + data=USER_INPUT + | { + CONF_MODEL: mock_lamarzocco.model, + CONF_TOKEN: "token", + CONF_NAME: "GS3", + }, + unique_id=mock_lamarzocco.serial_number, + ) @pytest.fixture @@ -131,17 +148,6 @@ def mock_lamarzocco(device_fixture: MachineModel) -> Generator[MagicMock]: yield lamarzocco -@pytest.fixture -def remove_local_connection( - hass: HomeAssistant, mock_config_entry: MockConfigEntry -) -> MockConfigEntry: - """Remove the local connection.""" - data = mock_config_entry.data.copy() - del data[CONF_HOST] - hass.config_entries.async_update_entry(mock_config_entry, data=data) - return mock_config_entry - - @pytest.fixture(autouse=True) def mock_bluetooth(enable_bluetooth: None) -> None: """Auto mock bluetooth.""" diff --git a/tests/components/lamarzocco/test_binary_sensor.py b/tests/components/lamarzocco/test_binary_sensor.py index d363b96ca214c..120d825c80463 100644 --- a/tests/components/lamarzocco/test_binary_sensor.py +++ b/tests/components/lamarzocco/test_binary_sensor.py @@ -5,7 +5,6 @@ from freezegun.api import FrozenDateTimeFactory from lmcloud.exceptions import RequestNotSuccessful -import pytest from syrupy import SnapshotAssertion from homeassistant.const import STATE_UNAVAILABLE @@ -47,15 +46,14 @@ async def test_binary_sensors( assert entry == snapshot(name=f"{serial_number}_{binary_sensor}-entry") -@pytest.mark.usefixtures("remove_local_connection") async def test_brew_active_does_not_exists( hass: HomeAssistant, mock_lamarzocco: MagicMock, - mock_config_entry: MockConfigEntry, + mock_config_entry_no_local_connection: MockConfigEntry, ) -> None: """Test the La Marzocco currently_making_coffee doesn't exist if host not set.""" - await async_init_integration(hass, mock_config_entry) + await async_init_integration(hass, mock_config_entry_no_local_connection) state = hass.states.get(f"sensor.{mock_lamarzocco.serial_number}_brewing_active") assert state is None diff --git a/tests/components/lamarzocco/test_sensor.py b/tests/components/lamarzocco/test_sensor.py index 1ce56724fa3a9..760dcffd28fff 100644 --- a/tests/components/lamarzocco/test_sensor.py +++ b/tests/components/lamarzocco/test_sensor.py @@ -47,15 +47,14 @@ async def test_sensors( assert entry == snapshot(name=f"{serial_number}_{sensor}-entry") -@pytest.mark.usefixtures("remove_local_connection") async def test_shot_timer_not_exists( hass: HomeAssistant, mock_lamarzocco: MagicMock, - mock_config_entry: MockConfigEntry, + mock_config_entry_no_local_connection: MockConfigEntry, ) -> None: """Test the La Marzocco shot timer doesn't exist if host not set.""" - await async_init_integration(hass, mock_config_entry) + await async_init_integration(hass, mock_config_entry_no_local_connection) state = hass.states.get(f"sensor.{mock_lamarzocco.serial_number}_shot_timer") assert state is None From 48a6dabc5ba56bcfdeb38b7f72009a45e2563275 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Thu, 3 Oct 2024 21:44:30 +0200 Subject: [PATCH 03/10] Remove Spider integration (#127346) --- CODEOWNERS | 2 - homeassistant/components/spider/__init__.py | 104 ++++--------- homeassistant/components/spider/climate.py | 144 ------------------ .../components/spider/config_flow.py | 84 +--------- homeassistant/components/spider/const.py | 8 - homeassistant/components/spider/manifest.json | 7 +- homeassistant/components/spider/sensor.py | 108 ------------- homeassistant/components/spider/strings.json | 30 +--- homeassistant/components/spider/switch.py | 74 --------- homeassistant/generated/config_flows.py | 1 - homeassistant/generated/integrations.json | 6 - requirements_all.txt | 3 - requirements_test_all.txt | 3 - tests/components/spider/__init__.py | 2 +- tests/components/spider/test_config_flow.py | 112 -------------- tests/components/spider/test_init.py | 50 ++++++ 16 files changed, 90 insertions(+), 648 deletions(-) delete mode 100644 homeassistant/components/spider/climate.py delete mode 100644 homeassistant/components/spider/const.py delete mode 100644 homeassistant/components/spider/sensor.py delete mode 100644 homeassistant/components/spider/switch.py delete mode 100644 tests/components/spider/test_config_flow.py create mode 100644 tests/components/spider/test_init.py diff --git a/CODEOWNERS b/CODEOWNERS index 36ed63175f250..64a8ef5abfa32 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1384,8 +1384,6 @@ build.json @home-assistant/supervisor /tests/components/spaceapi/ @fabaff /homeassistant/components/speedtestdotnet/ @rohankapoorcom @engrbm87 /tests/components/speedtestdotnet/ @rohankapoorcom @engrbm87 -/homeassistant/components/spider/ @peternijssen -/tests/components/spider/ @peternijssen /homeassistant/components/splunk/ @Bre77 /homeassistant/components/spotify/ @frenck @joostlek /tests/components/spotify/ @frenck @joostlek diff --git a/homeassistant/components/spider/__init__.py b/homeassistant/components/spider/__init__.py index 782486de2d8d9..4b138ec77a8c2 100644 --- a/homeassistant/components/spider/__init__.py +++ b/homeassistant/components/spider/__init__.py @@ -1,87 +1,39 @@ -"""Support for Spider Smart devices.""" +"""The Spider integration.""" -import logging +from __future__ import annotations -from spiderpy.spiderapi import SpiderApi, SpiderApiException, UnauthorizedException -import voluptuous as vol - -from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry -from homeassistant.const import CONF_PASSWORD, CONF_SCAN_INTERVAL, CONF_USERNAME +from homeassistant.config_entries import ConfigEntry, ConfigEntryState from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryNotReady -import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.typing import ConfigType - -from .const import DEFAULT_SCAN_INTERVAL, DOMAIN, PLATFORMS - -_LOGGER = logging.getLogger(__name__) - -CONFIG_SCHEMA = vol.Schema( - vol.All( - cv.deprecated(DOMAIN), - { - DOMAIN: vol.Schema( - { - vol.Required(CONF_PASSWORD): cv.string, - vol.Required(CONF_USERNAME): cv.string, - vol.Optional( - CONF_SCAN_INTERVAL, default=DEFAULT_SCAN_INTERVAL - ): cv.time_period, - } - ) +from homeassistant.helpers import issue_registry as ir + +DOMAIN = "spider" + + +async def async_setup_entry(hass: HomeAssistant, _: ConfigEntry) -> bool: + """Set up Spider from a config entry.""" + ir.async_create_issue( + hass, + DOMAIN, + DOMAIN, + is_fixable=False, + severity=ir.IssueSeverity.ERROR, + translation_key="integration_removed", + translation_placeholders={ + "link": "https://www.ithodaalderop.nl/additionelespiderproducten", + "entries": "/config/integrations/integration/spider", }, - ), - extra=vol.ALLOW_EXTRA, -) - - -async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: - """Set up a config entry.""" - hass.data[DOMAIN] = {} - if DOMAIN not in config: - return True - - conf = config[DOMAIN] - - if not hass.config_entries.async_entries(DOMAIN): - hass.async_create_task( - hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_IMPORT}, data=conf - ) - ) - - return True - - -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: - """Set up Spider via config entry.""" - try: - api = await hass.async_add_executor_job( - SpiderApi, - entry.data[CONF_USERNAME], - entry.data[CONF_PASSWORD], - entry.data[CONF_SCAN_INTERVAL], - ) - except UnauthorizedException: - _LOGGER.error("Authorization failed") - return False - except SpiderApiException as err: - _LOGGER.error("Can't connect to the Spider API: %s", err) - raise ConfigEntryNotReady from err - - hass.data[DOMAIN][entry.entry_id] = api - - await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + ) return True async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: - """Unload Spider entry.""" - unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) - if not unload_ok: - return False - - hass.data[DOMAIN].pop(entry.entry_id) + """Unload a config entry.""" + if all( + config_entry.state is ConfigEntryState.NOT_LOADED + for config_entry in hass.config_entries.async_entries(DOMAIN) + if config_entry.entry_id != entry.entry_id + ): + ir.async_delete_issue(hass, DOMAIN, DOMAIN) return True diff --git a/homeassistant/components/spider/climate.py b/homeassistant/components/spider/climate.py deleted file mode 100644 index 11e84a942f413..0000000000000 --- a/homeassistant/components/spider/climate.py +++ /dev/null @@ -1,144 +0,0 @@ -"""Support for Spider thermostats.""" - -from typing import Any - -from homeassistant.components.climate import ( - ClimateEntity, - ClimateEntityFeature, - HVACMode, -) -from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature -from homeassistant.core import HomeAssistant -from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback - -from .const import DOMAIN - -HA_STATE_TO_SPIDER = { - HVACMode.COOL: "Cool", - HVACMode.HEAT: "Heat", - HVACMode.OFF: "Idle", -} - -SPIDER_STATE_TO_HA = {value: key for key, value in HA_STATE_TO_SPIDER.items()} - - -async def async_setup_entry( - hass: HomeAssistant, config: ConfigEntry, async_add_entities: AddEntitiesCallback -) -> None: - """Initialize a Spider thermostat.""" - api = hass.data[DOMAIN][config.entry_id] - - async_add_entities( - [ - SpiderThermostat(api, entity) - for entity in await hass.async_add_executor_job(api.get_thermostats) - ] - ) - - -class SpiderThermostat(ClimateEntity): - """Representation of a thermostat.""" - - _attr_has_entity_name = True - _attr_name = None - _attr_temperature_unit = UnitOfTemperature.CELSIUS - _enable_turn_on_off_backwards_compatibility = False - - def __init__(self, api, thermostat): - """Initialize the thermostat.""" - self.api = api - self.thermostat = thermostat - self.support_fan = thermostat.fan_speed_values - self.support_hvac = [] - for operation_value in thermostat.operation_values: - if operation_value in SPIDER_STATE_TO_HA: - self.support_hvac.append(SPIDER_STATE_TO_HA[operation_value]) - self._attr_supported_features |= ClimateEntityFeature.TARGET_TEMPERATURE - if len(self.hvac_modes) > 1 and HVACMode.OFF in self.hvac_modes: - self._attr_supported_features |= ( - ClimateEntityFeature.TURN_OFF | ClimateEntityFeature.TURN_ON - ) - if thermostat.has_fan_mode: - self._attr_supported_features |= ClimateEntityFeature.FAN_MODE - - @property - def device_info(self) -> DeviceInfo: - """Return the device_info of the device.""" - return DeviceInfo( - configuration_url="https://mijn.ithodaalderop.nl/", - identifiers={(DOMAIN, self.thermostat.id)}, - manufacturer=self.thermostat.manufacturer, - model=self.thermostat.model, - name=self.thermostat.name, - ) - - @property - def unique_id(self): - """Return the id of the thermostat, if any.""" - return self.thermostat.id - - @property - def current_temperature(self): - """Return the current temperature.""" - return self.thermostat.current_temperature - - @property - def target_temperature(self): - """Return the temperature we try to reach.""" - return self.thermostat.target_temperature - - @property - def target_temperature_step(self): - """Return the supported step of target temperature.""" - return self.thermostat.temperature_steps - - @property - def min_temp(self): - """Return the minimum temperature.""" - return self.thermostat.minimum_temperature - - @property - def max_temp(self): - """Return the maximum temperature.""" - return self.thermostat.maximum_temperature - - @property - def hvac_mode(self) -> HVACMode: - """Return current operation ie. heat, cool, idle.""" - return SPIDER_STATE_TO_HA[self.thermostat.operation_mode] - - @property - def hvac_modes(self) -> list[HVACMode]: - """Return the list of available operation modes.""" - return self.support_hvac - - def set_temperature(self, **kwargs: Any) -> None: - """Set new target temperature.""" - if (temperature := kwargs.get(ATTR_TEMPERATURE)) is None: - return - - self.thermostat.set_temperature(temperature) - - def set_hvac_mode(self, hvac_mode: HVACMode) -> None: - """Set new target operation mode.""" - self.thermostat.set_operation_mode(HA_STATE_TO_SPIDER.get(hvac_mode)) - - @property - def fan_mode(self): - """Return the fan setting.""" - return self.thermostat.current_fan_speed - - def set_fan_mode(self, fan_mode: str) -> None: - """Set fan mode.""" - self.thermostat.set_fan_speed(fan_mode) - - @property - def fan_modes(self): - """List of available fan modes.""" - return self.support_fan - - def update(self) -> None: - """Get the latest data.""" - self.thermostat = self.api.get_thermostat(self.unique_id) diff --git a/homeassistant/components/spider/config_flow.py b/homeassistant/components/spider/config_flow.py index 0c305adbc394b..d96fb9e88b657 100644 --- a/homeassistant/components/spider/config_flow.py +++ b/homeassistant/components/spider/config_flow.py @@ -1,87 +1,11 @@ -"""Config flow for Spider.""" +"""Config flow for Spider integration.""" -import logging -from typing import Any +from homeassistant.config_entries import ConfigFlow -from spiderpy.spiderapi import SpiderApi, SpiderApiException, UnauthorizedException -import voluptuous as vol - -from homeassistant.config_entries import ConfigFlow, ConfigFlowResult -from homeassistant.const import CONF_PASSWORD, CONF_SCAN_INTERVAL, CONF_USERNAME - -from .const import DEFAULT_SCAN_INTERVAL, DOMAIN - -_LOGGER = logging.getLogger(__name__) - -DATA_SCHEMA_USER = vol.Schema( - {vol.Required(CONF_USERNAME): str, vol.Required(CONF_PASSWORD): str} -) - -RESULT_AUTH_FAILED = "auth_failed" -RESULT_CONN_ERROR = "conn_error" -RESULT_SUCCESS = "success" +from . import DOMAIN class SpiderConfigFlow(ConfigFlow, domain=DOMAIN): - """Handle a Spider config flow.""" + """Handle a config flow for Spider.""" VERSION = 1 - - def __init__(self) -> None: - """Initialize the Spider flow.""" - self.data = { - CONF_USERNAME: "", - CONF_PASSWORD: "", - CONF_SCAN_INTERVAL: DEFAULT_SCAN_INTERVAL, - } - - def _try_connect(self): - """Try to connect and check auth.""" - try: - SpiderApi( - self.data[CONF_USERNAME], - self.data[CONF_PASSWORD], - self.data[CONF_SCAN_INTERVAL], - ) - except SpiderApiException: - return RESULT_CONN_ERROR - except UnauthorizedException: - return RESULT_AUTH_FAILED - - return RESULT_SUCCESS - - async def async_step_user( - self, user_input: dict[str, Any] | None = None - ) -> ConfigFlowResult: - """Handle a flow initiated by the user.""" - if self._async_current_entries(): - return self.async_abort(reason="single_instance_allowed") - - errors = {} - if user_input is not None: - self.data[CONF_USERNAME] = user_input["username"] - self.data[CONF_PASSWORD] = user_input["password"] - - result = await self.hass.async_add_executor_job(self._try_connect) - - if result == RESULT_SUCCESS: - return self.async_create_entry( - title=DOMAIN, - data=self.data, - ) - if result != RESULT_AUTH_FAILED: - _LOGGER.exception("Unexpected exception") - errors["base"] = "unknown" - return self.async_abort(reason=result) - - errors["base"] = "invalid_auth" - - return self.async_show_form( - step_id="user", - data_schema=DATA_SCHEMA_USER, - errors=errors, - ) - - async def async_step_import(self, import_data: dict[str, Any]) -> ConfigFlowResult: - """Import spider config from configuration.yaml.""" - return await self.async_step_user(import_data) diff --git a/homeassistant/components/spider/const.py b/homeassistant/components/spider/const.py deleted file mode 100644 index 189763f4e986e..0000000000000 --- a/homeassistant/components/spider/const.py +++ /dev/null @@ -1,8 +0,0 @@ -"""Constants for the Spider integration.""" - -from homeassistant.const import Platform - -DOMAIN = "spider" -DEFAULT_SCAN_INTERVAL = 300 - -PLATFORMS = [Platform.CLIMATE, Platform.SENSOR, Platform.SWITCH] diff --git a/homeassistant/components/spider/manifest.json b/homeassistant/components/spider/manifest.json index a80fd178898b2..76d148954f29f 100644 --- a/homeassistant/components/spider/manifest.json +++ b/homeassistant/components/spider/manifest.json @@ -1,10 +1,9 @@ { "domain": "spider", "name": "Itho Daalderop Spider", - "codeowners": ["@peternijssen"], - "config_flow": true, + "codeowners": [], "documentation": "https://www.home-assistant.io/integrations/spider", + "integration_type": "system", "iot_class": "cloud_polling", - "loggers": ["spiderpy"], - "requirements": ["spiderpy==1.6.1"] + "requirements": [] } diff --git a/homeassistant/components/spider/sensor.py b/homeassistant/components/spider/sensor.py deleted file mode 100644 index 70c38a40e15ec..0000000000000 --- a/homeassistant/components/spider/sensor.py +++ /dev/null @@ -1,108 +0,0 @@ -"""Support for Spider Powerplugs (energy & power).""" - -from __future__ import annotations - -from homeassistant.components.sensor import ( - SensorDeviceClass, - SensorEntity, - SensorStateClass, -) -from homeassistant.config_entries import ConfigEntry -from homeassistant.const import UnitOfEnergy, UnitOfPower -from homeassistant.core import HomeAssistant -from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback - -from .const import DOMAIN - - -async def async_setup_entry( - hass: HomeAssistant, config: ConfigEntry, async_add_entities: AddEntitiesCallback -) -> None: - """Initialize a Spider Power Plug.""" - api = hass.data[DOMAIN][config.entry_id] - entities: list[SensorEntity] = [] - - for entity in await hass.async_add_executor_job(api.get_power_plugs): - entities.append(SpiderPowerPlugEnergy(api, entity)) - entities.append(SpiderPowerPlugPower(api, entity)) - - async_add_entities(entities) - - -class SpiderPowerPlugEnergy(SensorEntity): - """Representation of a Spider Power Plug (energy).""" - - _attr_has_entity_name = True - _attr_translation_key = "total_energy_today" - _attr_native_unit_of_measurement = UnitOfEnergy.KILO_WATT_HOUR - _attr_device_class = SensorDeviceClass.ENERGY - _attr_state_class = SensorStateClass.TOTAL_INCREASING - - def __init__(self, api, power_plug) -> None: - """Initialize the Spider Power Plug.""" - self.api = api - self.power_plug = power_plug - - @property - def device_info(self) -> DeviceInfo: - """Return the device_info of the device.""" - return DeviceInfo( - identifiers={(DOMAIN, self.power_plug.id)}, - manufacturer=self.power_plug.manufacturer, - model=self.power_plug.model, - name=self.power_plug.name, - ) - - @property - def unique_id(self) -> str: - """Return the ID of this sensor.""" - return f"{self.power_plug.id}_total_energy_today" - - @property - def native_value(self) -> float: - """Return todays energy usage in Kwh.""" - return round(self.power_plug.today_energy_consumption / 1000, 2) - - def update(self) -> None: - """Get the latest data.""" - self.power_plug = self.api.get_power_plug(self.power_plug.id) - - -class SpiderPowerPlugPower(SensorEntity): - """Representation of a Spider Power Plug (power).""" - - _attr_has_entity_name = True - _attr_translation_key = "power_consumption" - _attr_device_class = SensorDeviceClass.POWER - _attr_state_class = SensorStateClass.MEASUREMENT - _attr_native_unit_of_measurement = UnitOfPower.WATT - - def __init__(self, api, power_plug) -> None: - """Initialize the Spider Power Plug.""" - self.api = api - self.power_plug = power_plug - - @property - def device_info(self) -> DeviceInfo: - """Return the device_info of the device.""" - return DeviceInfo( - identifiers={(DOMAIN, self.power_plug.id)}, - manufacturer=self.power_plug.manufacturer, - model=self.power_plug.model, - name=self.power_plug.name, - ) - - @property - def unique_id(self) -> str: - """Return the ID of this sensor.""" - return f"{self.power_plug.id}_power_consumption" - - @property - def native_value(self) -> float: - """Return the current power usage in W.""" - return round(self.power_plug.current_energy_consumption) - - def update(self) -> None: - """Get the latest data.""" - self.power_plug = self.api.get_power_plug(self.power_plug.id) diff --git a/homeassistant/components/spider/strings.json b/homeassistant/components/spider/strings.json index c8d67be36ae4e..338ae3aa7623b 100644 --- a/homeassistant/components/spider/strings.json +++ b/homeassistant/components/spider/strings.json @@ -1,30 +1,8 @@ { - "config": { - "step": { - "user": { - "title": "Sign-in with mijn.ithodaalderop.nl account", - "data": { - "username": "[%key:common::config_flow::data::username%]", - "password": "[%key:common::config_flow::data::password%]" - } - } - }, - "error": { - "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", - "unknown": "[%key:common::config_flow::error::unknown%]" - }, - "abort": { - "single_instance_allowed": "[%key:common::config_flow::abort::single_instance_allowed%]" - } - }, - "entity": { - "sensor": { - "power_consumption": { - "name": "Power consumption" - }, - "total_energy_today": { - "name": "Total energy today" - } + "issues": { + "integration_removed": { + "title": "The Spider integration has been removed", + "description": "The Spider integration has been removed from Home Assistant.\n\nItho daalderop has [discontinued]({link}) the Spider Connect System.\n\nTo resolve this issue, please remove the (now defunct) integration entries from your Home Assistant setup. [Click here to see your existing Spider integration entries]({entries})." } } } diff --git a/homeassistant/components/spider/switch.py b/homeassistant/components/spider/switch.py deleted file mode 100644 index 63f0ec6cb6941..0000000000000 --- a/homeassistant/components/spider/switch.py +++ /dev/null @@ -1,74 +0,0 @@ -"""Support for Spider switches.""" - -from typing import Any - -from homeassistant.components.switch import SwitchEntity -from homeassistant.config_entries import ConfigEntry -from homeassistant.core import HomeAssistant -from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback - -from .const import DOMAIN - - -async def async_setup_entry( - hass: HomeAssistant, config: ConfigEntry, async_add_entities: AddEntitiesCallback -) -> None: - """Initialize a Spider Power Plug.""" - api = hass.data[DOMAIN][config.entry_id] - async_add_entities( - [ - SpiderPowerPlug(api, entity) - for entity in await hass.async_add_executor_job(api.get_power_plugs) - ] - ) - - -class SpiderPowerPlug(SwitchEntity): - """Representation of a Spider Power Plug.""" - - _attr_has_entity_name = True - _attr_name = None - - def __init__(self, api, power_plug): - """Initialize the Spider Power Plug.""" - self.api = api - self.power_plug = power_plug - - @property - def device_info(self) -> DeviceInfo: - """Return the device_info of the device.""" - return DeviceInfo( - configuration_url="https://mijn.ithodaalderop.nl/", - identifiers={(DOMAIN, self.power_plug.id)}, - manufacturer=self.power_plug.manufacturer, - model=self.power_plug.model, - name=self.power_plug.name, - ) - - @property - def unique_id(self): - """Return the ID of this switch.""" - return self.power_plug.id - - @property - def is_on(self): - """Return true if switch is on. Standby is on.""" - return self.power_plug.is_on - - @property - def available(self) -> bool: - """Return true if switch is available.""" - return self.power_plug.is_available - - def turn_on(self, **kwargs: Any) -> None: - """Turn device on.""" - self.power_plug.turn_on() - - def turn_off(self, **kwargs: Any) -> None: - """Turn device off.""" - self.power_plug.turn_off() - - def update(self) -> None: - """Get the latest data.""" - self.power_plug = self.api.get_power_plug(self.power_plug.id) diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 10e27ff2c975a..f399b0922f13c 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -554,7 +554,6 @@ "sonos", "soundtouch", "speedtestdotnet", - "spider", "spotify", "sql", "squeezebox", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 7b1cb0450413c..3243d1677ae29 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -5821,12 +5821,6 @@ "config_flow": true, "iot_class": "cloud_polling" }, - "spider": { - "name": "Itho Daalderop Spider", - "integration_type": "hub", - "config_flow": true, - "iot_class": "cloud_polling" - }, "splunk": { "name": "Splunk", "integration_type": "hub", diff --git a/requirements_all.txt b/requirements_all.txt index ec20231cb660e..cfc608876123b 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2696,9 +2696,6 @@ speak2mary==1.4.0 # homeassistant.components.speedtestdotnet speedtest-cli==2.1.3 -# homeassistant.components.spider -spiderpy==1.6.1 - # homeassistant.components.spotify spotipy==2.23.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index d656689788701..bdf63ae70d2da 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2142,9 +2142,6 @@ speak2mary==1.4.0 # homeassistant.components.speedtestdotnet speedtest-cli==2.1.3 -# homeassistant.components.spider -spiderpy==1.6.1 - # homeassistant.components.spotify spotipy==2.23.0 diff --git a/tests/components/spider/__init__.py b/tests/components/spider/__init__.py index d145f4efc090e..4d9139a501ecd 100644 --- a/tests/components/spider/__init__.py +++ b/tests/components/spider/__init__.py @@ -1 +1 @@ -"""Tests for the Spider component.""" +"""Tests for the Spider integration.""" diff --git a/tests/components/spider/test_config_flow.py b/tests/components/spider/test_config_flow.py deleted file mode 100644 index 69f97130f8c55..0000000000000 --- a/tests/components/spider/test_config_flow.py +++ /dev/null @@ -1,112 +0,0 @@ -"""Tests for the Spider config flow.""" - -from unittest.mock import Mock, patch - -import pytest - -from homeassistant import config_entries -from homeassistant.components.spider.const import DOMAIN -from homeassistant.const import CONF_PASSWORD, CONF_USERNAME -from homeassistant.core import HomeAssistant -from homeassistant.data_entry_flow import FlowResultType - -from tests.common import MockConfigEntry - -USERNAME = "spider-username" -PASSWORD = "spider-password" - -SPIDER_USER_DATA = { - CONF_USERNAME: USERNAME, - CONF_PASSWORD: PASSWORD, -} - - -@pytest.fixture(name="spider") -def spider_fixture() -> Mock: - """Patch libraries.""" - with patch("homeassistant.components.spider.config_flow.SpiderApi") as spider: - yield spider - - -async def test_user(hass: HomeAssistant, spider) -> None: - """Test user config.""" - - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} - ) - - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "user" - - with ( - patch( - "homeassistant.components.spider.async_setup", return_value=True - ) as mock_setup, - patch( - "homeassistant.components.spider.async_setup_entry", return_value=True - ) as mock_setup_entry, - ): - result = await hass.config_entries.flow.async_configure( - result["flow_id"], user_input=SPIDER_USER_DATA - ) - await hass.async_block_till_done() - - assert result["type"] is FlowResultType.CREATE_ENTRY - assert result["title"] == DOMAIN - assert result["data"][CONF_USERNAME] == USERNAME - assert result["data"][CONF_PASSWORD] == PASSWORD - assert not result["result"].unique_id - - assert len(mock_setup.mock_calls) == 1 - assert len(mock_setup_entry.mock_calls) == 1 - - -async def test_import(hass: HomeAssistant, spider) -> None: - """Test import step.""" - - with ( - patch( - "homeassistant.components.spider.async_setup", - return_value=True, - ) as mock_setup, - patch( - "homeassistant.components.spider.async_setup_entry", - return_value=True, - ) as mock_setup_entry, - ): - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_IMPORT}, - data=SPIDER_USER_DATA, - ) - await hass.async_block_till_done() - - assert result["type"] is FlowResultType.CREATE_ENTRY - assert result["title"] == DOMAIN - assert result["data"][CONF_USERNAME] == USERNAME - assert result["data"][CONF_PASSWORD] == PASSWORD - assert not result["result"].unique_id - - assert len(mock_setup.mock_calls) == 1 - assert len(mock_setup_entry.mock_calls) == 1 - - -async def test_abort_if_already_setup(hass: HomeAssistant, spider) -> None: - """Test we abort if Spider is already setup.""" - MockConfigEntry(domain=DOMAIN, data=SPIDER_USER_DATA).add_to_hass(hass) - - # Should fail, config exist (import) - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER}, data=SPIDER_USER_DATA - ) - - assert result["type"] is FlowResultType.ABORT - assert result["reason"] == "single_instance_allowed" - - # Should fail, config exist (flow) - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_IMPORT}, data=SPIDER_USER_DATA - ) - - assert result["type"] is FlowResultType.ABORT - assert result["reason"] == "single_instance_allowed" diff --git a/tests/components/spider/test_init.py b/tests/components/spider/test_init.py new file mode 100644 index 0000000000000..6d1d87cfa6a3a --- /dev/null +++ b/tests/components/spider/test_init.py @@ -0,0 +1,50 @@ +"""Tests for the Spider integration.""" + +from homeassistant.components.spider import DOMAIN +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant +from homeassistant.helpers import issue_registry as ir + +from tests.common import MockConfigEntry + + +async def test_spider_repair_issue( + hass: HomeAssistant, issue_registry: ir.IssueRegistry +) -> None: + """Test the Spider configuration entry loading/unloading handles the repair.""" + config_entry_1 = MockConfigEntry( + title="Example 1", + domain=DOMAIN, + ) + config_entry_1.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry_1.entry_id) + await hass.async_block_till_done() + assert config_entry_1.state is ConfigEntryState.LOADED + + # Add a second one + config_entry_2 = MockConfigEntry( + title="Example 2", + domain=DOMAIN, + ) + config_entry_2.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry_2.entry_id) + await hass.async_block_till_done() + + assert config_entry_2.state is ConfigEntryState.LOADED + assert issue_registry.async_get_issue(DOMAIN, DOMAIN) + + # Remove the first one + await hass.config_entries.async_remove(config_entry_1.entry_id) + await hass.async_block_till_done() + + assert config_entry_1.state is ConfigEntryState.NOT_LOADED + assert config_entry_2.state is ConfigEntryState.LOADED + assert issue_registry.async_get_issue(DOMAIN, DOMAIN) + + # Remove the second one + await hass.config_entries.async_remove(config_entry_2.entry_id) + await hass.async_block_till_done() + + assert config_entry_1.state is ConfigEntryState.NOT_LOADED + assert config_entry_2.state is ConfigEntryState.NOT_LOADED + assert issue_registry.async_get_issue(DOMAIN, DOMAIN) is None From 68d58212a961d50fa746c661a91a53c2ae34a38a Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Thu, 3 Oct 2024 22:20:20 +0200 Subject: [PATCH 04/10] Adjust type hints in hyperion config_flow (#127273) --- homeassistant/components/hyperion/config_flow.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/homeassistant/components/hyperion/config_flow.py b/homeassistant/components/hyperion/config_flow.py index 64a9831800fd9..161c531328de5 100644 --- a/homeassistant/components/hyperion/config_flow.py +++ b/homeassistant/components/hyperion/config_flow.py @@ -111,6 +111,8 @@ class HyperionConfigFlow(ConfigFlow, domain=DOMAIN): VERSION = 1 + unique_id: str + def __init__(self) -> None: """Instantiate config flow.""" self._data: dict[str, Any] = {} From db494de809cf0e84eab139457639f0db075c754e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 3 Oct 2024 15:23:47 -0500 Subject: [PATCH 05/10] Restore __slots__ to core objects (#127441) --- .../components/recorder/models/legacy.py | 1 + .../components/template/template_entity.py | 4 +- homeassistant/core.py | 76 +++++++++++++------ homeassistant/helpers/template.py | 14 +++- tests/components/recorder/db_schema_16.py | 2 - tests/components/recorder/db_schema_18.py | 2 - tests/components/recorder/db_schema_22.py | 2 - tests/components/recorder/db_schema_23.py | 2 - .../db_schema_23_with_newer_columns.py | 2 - 9 files changed, 67 insertions(+), 38 deletions(-) diff --git a/homeassistant/components/recorder/models/legacy.py b/homeassistant/components/recorder/models/legacy.py index b62afc433ef90..21a8a39ba0fc4 100644 --- a/homeassistant/components/recorder/models/legacy.py +++ b/homeassistant/components/recorder/models/legacy.py @@ -28,6 +28,7 @@ class LegacyLazyState(State): "_attributes", "_last_changed_ts", "_last_updated_ts", + "_last_reported_ts", "_context", "attr_cache", ] diff --git a/homeassistant/components/template/template_entity.py b/homeassistant/components/template/template_entity.py index c881b0ff2bbba..ebb6aa3a48c49 100644 --- a/homeassistant/components/template/template_entity.py +++ b/homeassistant/components/template/template_entity.py @@ -8,7 +8,7 @@ import logging from typing import Any -from propcache import cached_property +from propcache import under_cached_property import voluptuous as vol from homeassistant.const import ( @@ -302,7 +302,7 @@ def __init__(self) -> None: super().__init__("unknown.unknown", STATE_UNKNOWN) self.entity_id = None # type: ignore[assignment] - @cached_property + @under_cached_property def name(self) -> str: """Name of this state.""" return "" diff --git a/homeassistant/core.py b/homeassistant/core.py index 020b9f1f6b35f..82ec4956a94d6 100644 --- a/homeassistant/core.py +++ b/homeassistant/core.py @@ -44,7 +44,7 @@ ) from urllib.parse import urlparse -from propcache import cached_property +from propcache import cached_property, under_cached_property from typing_extensions import TypeVar import voluptuous as vol import yarl @@ -335,6 +335,8 @@ class HassJob[**_P, _R_co]: we run the job. """ + __slots__ = ("target", "name", "_cancel_on_shutdown", "_cache") + def __init__( self, target: Callable[_P, _R_co], @@ -347,12 +349,13 @@ def __init__( self.target: Final = target self.name = name self._cancel_on_shutdown = cancel_on_shutdown + self._cache: dict[str, Any] = {} if job_type: # Pre-set the cached_property so we # avoid the function call - self.__dict__["job_type"] = job_type + self._cache["job_type"] = job_type - @cached_property + @under_cached_property def job_type(self) -> HassJobType: """Return the job type.""" return get_hassjob_callable_job_type(self.target) @@ -1244,6 +1247,8 @@ def _async_log_running_tasks(self, stage: str) -> None: class Context: """The context that triggered something.""" + __slots__ = ("id", "user_id", "parent_id", "origin_event", "_cache") + def __init__( self, user_id: str | None = None, @@ -1255,6 +1260,7 @@ def __init__( self.user_id = user_id self.parent_id = parent_id self.origin_event: Event[Any] | None = None + self._cache: dict[str, Any] = {} def __eq__(self, other: object) -> bool: """Compare contexts.""" @@ -1268,7 +1274,7 @@ def __deepcopy__(self, memo: dict[int, Any]) -> Context: """Create a deep copy of this context.""" return Context(user_id=self.user_id, parent_id=self.parent_id, id=self.id) - @cached_property + @under_cached_property def _as_dict(self) -> dict[str, str | None]: """Return a dictionary representation of the context. @@ -1285,12 +1291,12 @@ def as_dict(self) -> ReadOnlyDict[str, str | None]: """Return a ReadOnlyDict representation of the context.""" return self._as_read_only_dict - @cached_property + @under_cached_property def _as_read_only_dict(self) -> ReadOnlyDict[str, str | None]: """Return a ReadOnlyDict representation of the context.""" return ReadOnlyDict(self._as_dict) - @cached_property + @under_cached_property def json_fragment(self) -> json_fragment: """Return a JSON fragment of the context.""" return json_fragment(json_bytes(self._as_dict)) @@ -1315,6 +1321,15 @@ def idx(self) -> int: class Event(Generic[_DataT]): """Representation of an event within the bus.""" + __slots__ = ( + "event_type", + "data", + "origin", + "time_fired_timestamp", + "context", + "_cache", + ) + def __init__( self, event_type: EventType[_DataT] | str, @@ -1333,13 +1348,14 @@ def __init__( self.context = context if not context.origin_event: context.origin_event = self + self._cache: dict[str, Any] = {} - @cached_property + @under_cached_property def time_fired(self) -> datetime.datetime: """Return time fired as a timestamp.""" return dt_util.utc_from_timestamp(self.time_fired_timestamp) - @cached_property + @under_cached_property def _as_dict(self) -> dict[str, Any]: """Create a dict representation of this Event. @@ -1364,7 +1380,7 @@ def as_dict(self) -> ReadOnlyDict[str, Any]: """ return self._as_read_only_dict - @cached_property + @under_cached_property def _as_read_only_dict(self) -> ReadOnlyDict[str, Any]: """Create a ReadOnlyDict representation of this Event.""" as_dict = self._as_dict @@ -1380,7 +1396,7 @@ def _as_read_only_dict(self) -> ReadOnlyDict[str, Any]: as_dict["context"] = ReadOnlyDict(context) return ReadOnlyDict(as_dict) - @cached_property + @under_cached_property def json_fragment(self) -> json_fragment: """Return an event as a JSON fragment.""" return json_fragment(json_bytes(self._as_dict)) @@ -1751,6 +1767,21 @@ class State: object_id: Object id of this state. """ + __slots__ = ( + "entity_id", + "state", + "attributes", + "last_changed", + "last_reported", + "last_updated", + "context", + "state_info", + "domain", + "object_id", + "last_updated_timestamp", + "_cache", + ) + def __init__( self, entity_id: str, @@ -1765,6 +1796,7 @@ def __init__( last_updated_timestamp: float | None = None, ) -> None: """Initialize a new state.""" + self._cache: dict[str, Any] = {} state = str(state) if validate_entity_id and not valid_entity_id(entity_id): @@ -1798,31 +1830,31 @@ def __init__( last_updated_timestamp = last_updated.timestamp() self.last_updated_timestamp = last_updated_timestamp if self.last_changed == last_updated: - self.__dict__["last_changed_timestamp"] = last_updated_timestamp + self._cache["last_changed_timestamp"] = last_updated_timestamp # If last_reported is the same as last_updated async_set will pass # the same datetime object for both values so we can use an identity # check here. if self.last_reported is last_updated: - self.__dict__["last_reported_timestamp"] = last_updated_timestamp + self._cache["last_reported_timestamp"] = last_updated_timestamp - @cached_property + @under_cached_property def name(self) -> str: """Name of this state.""" return self.attributes.get(ATTR_FRIENDLY_NAME) or self.object_id.replace( "_", " " ) - @cached_property + @under_cached_property def last_changed_timestamp(self) -> float: """Timestamp of last change.""" return self.last_changed.timestamp() - @cached_property + @under_cached_property def last_reported_timestamp(self) -> float: """Timestamp of last report.""" return self.last_reported.timestamp() - @cached_property + @under_cached_property def _as_dict(self) -> dict[str, Any]: """Return a dict representation of the State. @@ -1863,7 +1895,7 @@ def as_dict( """ return self._as_read_only_dict - @cached_property + @under_cached_property def _as_read_only_dict( self, ) -> ReadOnlyDict[str, datetime.datetime | Collection[Any]]: @@ -1878,17 +1910,17 @@ def _as_read_only_dict( as_dict["context"] = ReadOnlyDict(context) return ReadOnlyDict(as_dict) - @cached_property + @under_cached_property def as_dict_json(self) -> bytes: """Return a JSON string of the State.""" return json_bytes(self._as_dict) - @cached_property + @under_cached_property def json_fragment(self) -> json_fragment: """Return a JSON fragment of the State.""" return json_fragment(self.as_dict_json) - @cached_property + @under_cached_property def as_compressed_state(self) -> CompressedState: """Build a compressed dict of a state for adds. @@ -1916,7 +1948,7 @@ def as_compressed_state(self) -> CompressedState: ) return compressed_state - @cached_property + @under_cached_property def as_compressed_state_json(self) -> bytes: """Build a compressed JSON key value pair of a state for adds. @@ -2308,7 +2340,7 @@ def async_set_internal( # mypy does not understand this is only possible if old_state is not None old_last_reported = old_state.last_reported # type: ignore[union-attr] old_state.last_reported = now # type: ignore[union-attr] - old_state.last_reported_timestamp = timestamp # type: ignore[union-attr] + old_state._cache["last_reported_timestamp"] = timestamp # type: ignore[union-attr] # noqa: SLF001 # Avoid creating an EventStateReportedData self._bus.async_fire_internal( # type: ignore[misc] EVENT_STATE_REPORTED, diff --git a/homeassistant/helpers/template.py b/homeassistant/helpers/template.py index 9f8eb628e63bd..5d5fd3df39ac6 100644 --- a/homeassistant/helpers/template.py +++ b/homeassistant/helpers/template.py @@ -10,7 +10,7 @@ from contextlib import AbstractContextManager from contextvars import ContextVar from datetime import date, datetime, time, timedelta -from functools import cache, cached_property, lru_cache, partial, wraps +from functools import cache, lru_cache, partial, wraps import json import logging import math @@ -34,6 +34,7 @@ from jinja2.utils import Namespace from lru import LRU import orjson +from propcache import under_cached_property import voluptuous as vol from homeassistant.const import ( @@ -1023,6 +1024,8 @@ def __repr__(self) -> str: class TemplateStateBase(State): """Class to represent a state object in a template.""" + __slots__ = ("_hass", "_collect", "_entity_id", "_state") + _state: State __setitem__ = _readonly @@ -1035,6 +1038,7 @@ def __init__(self, hass: HomeAssistant, collect: bool, entity_id: str) -> None: self._hass = hass self._collect = collect self._entity_id = entity_id + self._cache: dict[str, Any] = {} def _collect_state(self) -> None: if self._collect and (render_info := _render_info.get()): @@ -1055,7 +1059,7 @@ def __getitem__(self, item: str) -> Any: return self.state_with_unit raise KeyError - @cached_property + @under_cached_property def entity_id(self) -> str: # type: ignore[override] """Wrap State.entity_id. @@ -1112,7 +1116,7 @@ def object_id(self) -> str: # type: ignore[override] return self._state.object_id @property - def name(self) -> str: + def name(self) -> str: # type: ignore[override] """Wrap State.name.""" self._collect_state() return self._state.name @@ -1149,7 +1153,7 @@ def __eq__(self, other: object) -> bool: class TemplateState(TemplateStateBase): """Class to represent a state object in a template.""" - __slots__ = ("_state",) + __slots__ = () # Inheritance is done so functions that check against State keep working def __init__(self, hass: HomeAssistant, state: State, collect: bool = True) -> None: @@ -1165,6 +1169,8 @@ def __repr__(self) -> str: class TemplateStateFromEntityId(TemplateStateBase): """Class to represent a state object in a template.""" + __slots__ = () + def __init__( self, hass: HomeAssistant, entity_id: str, collect: bool = True ) -> None: diff --git a/tests/components/recorder/db_schema_16.py b/tests/components/recorder/db_schema_16.py index ffee438f2e9be..d7ca35c9341c4 100644 --- a/tests/components/recorder/db_schema_16.py +++ b/tests/components/recorder/db_schema_16.py @@ -348,8 +348,6 @@ class LazyState(State): __slots__ = [ "_row", - "entity_id", - "state", "_attributes", "_last_changed", "_last_updated", diff --git a/tests/components/recorder/db_schema_18.py b/tests/components/recorder/db_schema_18.py index 09cd41d9e33cd..adb71dffb9ee8 100644 --- a/tests/components/recorder/db_schema_18.py +++ b/tests/components/recorder/db_schema_18.py @@ -361,8 +361,6 @@ class LazyState(State): __slots__ = [ "_row", - "entity_id", - "state", "_attributes", "_last_changed", "_last_updated", diff --git a/tests/components/recorder/db_schema_22.py b/tests/components/recorder/db_schema_22.py index d05cb48ff6f39..c0d607b12a74d 100644 --- a/tests/components/recorder/db_schema_22.py +++ b/tests/components/recorder/db_schema_22.py @@ -480,8 +480,6 @@ class LazyState(State): __slots__ = [ "_row", - "entity_id", - "state", "_attributes", "_last_changed", "_last_updated", diff --git a/tests/components/recorder/db_schema_23.py b/tests/components/recorder/db_schema_23.py index 9dffadaa0cc89..f60b7b49df493 100644 --- a/tests/components/recorder/db_schema_23.py +++ b/tests/components/recorder/db_schema_23.py @@ -470,8 +470,6 @@ class LazyState(State): __slots__ = [ "_row", - "entity_id", - "state", "_attributes", "_last_changed", "_last_updated", diff --git a/tests/components/recorder/db_schema_23_with_newer_columns.py b/tests/components/recorder/db_schema_23_with_newer_columns.py index 4343f53d00db6..4cc1074de413b 100644 --- a/tests/components/recorder/db_schema_23_with_newer_columns.py +++ b/tests/components/recorder/db_schema_23_with_newer_columns.py @@ -594,8 +594,6 @@ class LazyState(State): __slots__ = [ "_row", - "entity_id", - "state", "_attributes", "_last_changed", "_last_updated", From 80582a128a27d2af17939b3d532fdebe82344897 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Thu, 3 Oct 2024 22:27:01 +0200 Subject: [PATCH 06/10] Fix preview available in statistics (#127349) --- homeassistant/components/statistics/sensor.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/statistics/sensor.py b/homeassistant/components/statistics/sensor.py index b0a0dddd05d4d..ba98fe3ec6e35 100644 --- a/homeassistant/components/statistics/sensor.py +++ b/homeassistant/components/statistics/sensor.py @@ -385,7 +385,7 @@ def async_start_preview( if not self._source_entity_id or ( self._samples_max_buffer_size is None and self._samples_max_age is None ): - self._attr_available = False + self._available = False calculated_state = self._async_calculate_state() preview_callback(calculated_state.state, calculated_state.attributes) return self._call_on_remove_callbacks From 48a07d531ce15cffbf24b2984f8febeb5b787689 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Thu, 3 Oct 2024 22:27:15 +0200 Subject: [PATCH 07/10] Remove assumption in ConfigEntryItems about unique unique_id (#127399) --- homeassistant/config_entries.py | 17 ++++++++++------ tests/test_config_entries.py | 35 +++++++++++++++++++++++++++++++++ 2 files changed, 46 insertions(+), 6 deletions(-) diff --git a/homeassistant/config_entries.py b/homeassistant/config_entries.py index 5ad421755b2e5..aa4c7c49f9920 100644 --- a/homeassistant/config_entries.py +++ b/homeassistant/config_entries.py @@ -1600,7 +1600,7 @@ def __init__(self, hass: HomeAssistant) -> None: super().__init__() self._hass = hass self._domain_index: dict[str, list[ConfigEntry]] = {} - self._domain_unique_id_index: dict[str, dict[str, ConfigEntry]] = {} + self._domain_unique_id_index: dict[str, dict[str, list[ConfigEntry]]] = {} def values(self) -> ValuesView[ConfigEntry]: """Return the underlying values to avoid __iter__ overhead.""" @@ -1643,9 +1643,9 @@ def _index_entry(self, entry: ConfigEntry) -> None: report_issue, ) - self._domain_unique_id_index.setdefault(entry.domain, {})[ - unique_id_hash - ] = entry + self._domain_unique_id_index.setdefault(entry.domain, {}).setdefault( + unique_id_hash, [] + ).append(entry) def _unindex_entry(self, entry_id: str) -> None: """Unindex an entry.""" @@ -1658,7 +1658,9 @@ def _unindex_entry(self, entry_id: str) -> None: # Check type first to avoid expensive isinstance call if type(unique_id) is not str and not isinstance(unique_id, Hashable): # noqa: E721 unique_id = str(entry.unique_id) # type: ignore[unreachable] - del self._domain_unique_id_index[domain][unique_id] + self._domain_unique_id_index[domain][unique_id].remove(entry) + if not self._domain_unique_id_index[domain][unique_id]: + del self._domain_unique_id_index[domain][unique_id] if not self._domain_unique_id_index[domain]: del self._domain_unique_id_index[domain] @@ -1690,7 +1692,10 @@ def get_entry_by_domain_and_unique_id( # Check type first to avoid expensive isinstance call if type(unique_id) is not str and not isinstance(unique_id, Hashable): # noqa: E721 unique_id = str(unique_id) # type: ignore[unreachable] - return self._domain_unique_id_index.get(domain, {}).get(unique_id) + entries = self._domain_unique_id_index.get(domain, {}).get(unique_id) + if not entries: + return None + return entries[0] class ConfigEntryStore(storage.Store[dict[str, list[dict[str, Any]]]]): diff --git a/tests/test_config_entries.py b/tests/test_config_entries.py index 3151c512e19f7..0ab8620057dff 100644 --- a/tests/test_config_entries.py +++ b/tests/test_config_entries.py @@ -513,6 +513,41 @@ async def mock_setup_entry_platform( assert not entity_entry_list +async def test_remove_entry_non_unique_unique_id( + hass: HomeAssistant, + manager: config_entries.ConfigEntries, + entity_registry: er.EntityRegistry, +) -> None: + """Test that we can remove entry with colliding unique_id.""" + entry_1 = MockConfigEntry( + domain="test_other", entry_id="test1", unique_id="not_unique" + ) + entry_1.add_to_manager(manager) + entry_2 = MockConfigEntry( + domain="test_other", entry_id="test2", unique_id="not_unique" + ) + entry_2.add_to_manager(manager) + entry_3 = MockConfigEntry( + domain="test_other", entry_id="test3", unique_id="not_unique" + ) + entry_3.add_to_manager(manager) + + # Check all config entries exist + assert manager.async_entry_ids() == [ + "test1", + "test2", + "test3", + ] + + # Remove entries + assert await manager.async_remove("test1") == {"require_restart": False} + await hass.async_block_till_done() + assert await manager.async_remove("test2") == {"require_restart": False} + await hass.async_block_till_done() + assert await manager.async_remove("test3") == {"require_restart": False} + await hass.async_block_till_done() + + async def test_remove_entry_cancels_reauth( hass: HomeAssistant, manager: config_entries.ConfigEntries, From 10c033e58059d0640c25645f4bff71a5e27870e1 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 3 Oct 2024 15:28:00 -0500 Subject: [PATCH 08/10] Migrate config_entries to use propcache cached_property (#127495) --- homeassistant/config_entries.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/homeassistant/config_entries.py b/homeassistant/config_entries.py index aa4c7c49f9920..ee93f987a7962 100644 --- a/homeassistant/config_entries.py +++ b/homeassistant/config_entries.py @@ -18,13 +18,14 @@ from datetime import datetime from enum import Enum, StrEnum import functools -from functools import cache, cached_property +from functools import cache import logging from random import randint from types import MappingProxyType from typing import TYPE_CHECKING, Any, Generic, Self, cast from async_interrupt import interrupt +from propcache import cached_property from typing_extensions import TypeVar from . import data_entry_flow, loader From c3f0f3091047d4a2b88053dffc36879919ed923d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 3 Oct 2024 15:29:29 -0500 Subject: [PATCH 09/10] Prepare websocket writer for aiohttp 3.11 (#127043) --- homeassistant/components/websocket_api/http.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/websocket_api/http.py b/homeassistant/components/websocket_api/http.py index 1ad8d909ce83d..29dc611335065 100644 --- a/homeassistant/components/websocket_api/http.py +++ b/homeassistant/components/websocket_api/http.py @@ -328,7 +328,13 @@ async def async_handle(self) -> web.WebSocketResponse: if TYPE_CHECKING: assert writer is not None - send_bytes_text = partial(writer.send, binary=False) + # aiohttp 3.11.0 changed the method name from _send_frame to send_frame + if hasattr(writer, "send_frame"): + send_frame = writer.send_frame # pragma: no cover + else: + send_frame = writer._send_frame # noqa: SLF001 + + send_bytes_text = partial(send_frame, opcode=WSMsgType.TEXT) auth = AuthPhase( logger, hass, self._send_message, self._cancel, request, send_bytes_text ) From 1dd59375f638848d452c324245e3983f9b121ce9 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Thu, 3 Oct 2024 22:36:41 +0200 Subject: [PATCH 10/10] Add re-authemtication flow to AVM FRITZ!Box Call Monitor (#127497) --- .../fritzbox_callmonitor/__init__.py | 5 +- .../fritzbox_callmonitor/config_flow.py | 65 +++++++++++++ .../fritzbox_callmonitor/strings.json | 10 +- .../fritzbox_callmonitor/test_config_flow.py | 91 +++++++++++++++++++ 4 files changed, 167 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/fritzbox_callmonitor/__init__.py b/homeassistant/components/fritzbox_callmonitor/__init__.py index b33ba94cf16c0..b1b5db48216ec 100644 --- a/homeassistant/components/fritzbox_callmonitor/__init__.py +++ b/homeassistant/components/fritzbox_callmonitor/__init__.py @@ -8,7 +8,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from .base import FritzBoxPhonebook from .const import CONF_PHONEBOOK, CONF_PREFIXES, PLATFORMS @@ -42,8 +42,7 @@ async def async_setup_entry( ) return False except FritzConnectionException as ex: - _LOGGER.error("Invalid authentication: %s", ex) - return False + raise ConfigEntryAuthFailed from ex except RequestsConnectionError as ex: _LOGGER.error("Unable to connect to AVM FRITZ!Box call monitor: %s", ex) raise ConfigEntryNotReady from ex diff --git a/homeassistant/components/fritzbox_callmonitor/config_flow.py b/homeassistant/components/fritzbox_callmonitor/config_flow.py index 019326d840c2c..69efceae281bb 100644 --- a/homeassistant/components/fritzbox_callmonitor/config_flow.py +++ b/homeassistant/components/fritzbox_callmonitor/config_flow.py @@ -2,6 +2,7 @@ from __future__ import annotations +from collections.abc import Mapping from enum import StrEnum from typing import Any, cast @@ -65,6 +66,7 @@ class FritzBoxCallMonitorConfigFlow(ConfigFlow, domain=DOMAIN): VERSION = 1 + _entry: ConfigEntry _host: str _port: int _username: str @@ -209,6 +211,69 @@ async def async_step_phonebook( return self._get_config_entry() + async def async_step_reauth( + self, entry_data: Mapping[str, Any] + ) -> ConfigFlowResult: + """Handle flow upon an API authentication error.""" + self._entry = self._get_reauth_entry() + self._host = entry_data[CONF_HOST] + self._port = entry_data[CONF_PORT] + self._username = entry_data[CONF_USERNAME] + self._password = entry_data[CONF_PASSWORD] + self._phonebook_id = entry_data[CONF_PHONEBOOK] + + return await self.async_step_reauth_confirm() + + def _show_setup_form_reauth_confirm( + self, user_input: dict[str, Any], errors: dict[str, str] | None = None + ) -> ConfigFlowResult: + """Show the reauth form to the user.""" + default_username = user_input.get(CONF_USERNAME) + return self.async_show_form( + step_id="reauth_confirm", + data_schema=vol.Schema( + { + vol.Required(CONF_USERNAME, default=default_username): str, + vol.Required(CONF_PASSWORD): str, + } + ), + description_placeholders={"host": self._host}, + errors=errors or {}, + ) + + async def async_step_reauth_confirm( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Dialog that informs the user that reauth is required.""" + if user_input is None: + return self._show_setup_form_reauth_confirm( + user_input={CONF_USERNAME: self._username} + ) + + self._username = user_input[CONF_USERNAME] + self._password = user_input[CONF_PASSWORD] + + if ( + error := await self.hass.async_add_executor_job(self._try_connect) + ) is not ConnectResult.SUCCESS: + return self._show_setup_form_reauth_confirm( + user_input=user_input, errors={"base": error} + ) + + self.hass.config_entries.async_update_entry( + self._entry, + data={ + CONF_HOST: self._host, + CONF_PORT: self._port, + CONF_USERNAME: self._username, + CONF_PASSWORD: self._password, + CONF_PHONEBOOK: self._phonebook_id, + SERIAL_NUMBER: self._serial_number, + }, + ) + await self.hass.config_entries.async_reload(self._entry.entry_id) + return self.async_abort(reason="reauth_successful") + class FritzBoxCallMonitorOptionsFlowHandler(OptionsFlow): """Handle a fritzbox_callmonitor options flow.""" diff --git a/homeassistant/components/fritzbox_callmonitor/strings.json b/homeassistant/components/fritzbox_callmonitor/strings.json index bcfa945e1df5e..e935549035c75 100644 --- a/homeassistant/components/fritzbox_callmonitor/strings.json +++ b/homeassistant/components/fritzbox_callmonitor/strings.json @@ -17,14 +17,22 @@ "data": { "phonebook": "Phonebook" } + }, + "reauth_confirm": { + "data": { + "username": "[%key:common::config_flow::data::username%]", + "password": "[%key:common::config_flow::data::password%]" + } } }, "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", "no_devices_found": "[%key:common::config_flow::abort::no_devices_found%]", - "insufficient_permissions": "User has insufficient permissions to access AVM FRITZ!Box settings and its phonebooks." + "insufficient_permissions": "User has insufficient permissions to access AVM FRITZ!Box settings and its phonebooks.", + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" }, "error": { + "insufficient_permissions": "[%key:component::fritzbox_callmonitor::config::abort::insufficient_permissions%]", "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]" } }, diff --git a/tests/components/fritzbox_callmonitor/test_config_flow.py b/tests/components/fritzbox_callmonitor/test_config_flow.py index 14f18e84e0c41..0eccb651611c3 100644 --- a/tests/components/fritzbox_callmonitor/test_config_flow.py +++ b/tests/components/fritzbox_callmonitor/test_config_flow.py @@ -264,6 +264,97 @@ async def test_setup_invalid_auth( assert result["errors"] == {"base": ConnectResult.INVALID_AUTH} +async def test_reauth_successful(hass: HomeAssistant) -> None: + """Test starting a reauthentication flow.""" + mock_config = MockConfigEntry(domain=DOMAIN, data=MOCK_CONFIG_ENTRY) + mock_config.add_to_hass(hass) + result = await mock_config.start_reauth_flow(hass) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + + with ( + patch( + "homeassistant.components.fritzbox_callmonitor.base.FritzPhonebook.__init__", + return_value=None, + ), + patch( + "homeassistant.components.fritzbox_callmonitor.base.FritzPhonebook.phonebook_ids", + new_callable=PropertyMock, + return_value=[0], + ), + patch( + "homeassistant.components.fritzbox_callmonitor.base.FritzPhonebook.phonebook_info", + return_value=MOCK_PHONEBOOK_INFO_1, + ), + patch( + "homeassistant.components.fritzbox_callmonitor.base.FritzPhonebook.modelname", + return_value=MOCK_PHONEBOOK_NAME_1, + ), + patch( + "homeassistant.components.fritzbox_callmonitor.config_flow.FritzConnection.__init__", + return_value=None, + ), + patch( + "homeassistant.components.fritzbox_callmonitor.config_flow.FritzConnection.updatecheck", + new_callable=PropertyMock, + return_value=MOCK_DEVICE_INFO, + ), + patch( + "homeassistant.components.fritzbox_callmonitor.async_setup_entry", + return_value=True, + ) as mock_setup_entry, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_USERNAME: "other_fake_user", + CONF_PASSWORD: "other_fake_password", + }, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reauth_successful" + assert mock_config.data == { + **MOCK_CONFIG_ENTRY, + CONF_USERNAME: "other_fake_user", + CONF_PASSWORD: "other_fake_password", + } + assert len(mock_setup_entry.mock_calls) == 1 + + +@pytest.mark.parametrize( + ("side_effect", "error"), + [ + (FritzConnectionException, ConnectResult.INVALID_AUTH), + (FritzSecurityError, ConnectResult.INSUFFICIENT_PERMISSIONS), + ], +) +async def test_reauth_not_successful( + hass: HomeAssistant, side_effect: Exception, error: str +) -> None: + """Test starting a reauthentication flow but no connection found.""" + mock_config = MockConfigEntry(domain=DOMAIN, data=MOCK_CONFIG_ENTRY) + mock_config.add_to_hass(hass) + result = await mock_config.start_reauth_flow(hass) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + + with patch( + "homeassistant.components.fritzbox_callmonitor.base.FritzPhonebook.__init__", + side_effect=side_effect, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_USERNAME: "other_fake_user", + CONF_PASSWORD: "other_fake_password", + }, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + assert result["errors"]["base"] == error + + async def test_options_flow_correct_prefixes(hass: HomeAssistant) -> None: """Test config flow options."""