From 44d9eaea95c6019f066287a89d33b619c33d8868 Mon Sep 17 00:00:00 2001 From: Joakim Plate Date: Sat, 4 Oct 2025 12:25:35 +0200 Subject: [PATCH 1/8] Correct kraken test issues (#153601) --- homeassistant/components/kraken/__init__.py | 3 ++- homeassistant/components/kraken/sensor.py | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/kraken/__init__.py b/homeassistant/components/kraken/__init__.py index 5c3158bddf233..ccdd704d9df19 100644 --- a/homeassistant/components/kraken/__init__.py +++ b/homeassistant/components/kraken/__init__.py @@ -147,8 +147,9 @@ async def async_setup(self) -> None: def _get_websocket_name_asset_pairs(self) -> str: return ",".join( - self.tradable_asset_pairs[tracked_pair] + pair for tracked_pair in self._config_entry.options[CONF_TRACKED_ASSET_PAIRS] + if (pair := self.tradable_asset_pairs.get(tracked_pair)) is not None ) def set_update_interval(self, update_interval: int) -> None: diff --git a/homeassistant/components/kraken/sensor.py b/homeassistant/components/kraken/sensor.py index 1d3f36d29e419..2a6b36e17294b 100644 --- a/homeassistant/components/kraken/sensor.py +++ b/homeassistant/components/kraken/sensor.py @@ -156,7 +156,7 @@ def _async_add_kraken_sensors(tracked_asset_pairs: list[str]) -> None: for description in SENSOR_TYPES ] ) - async_add_entities(entities, True) + async_add_entities(entities) _async_add_kraken_sensors(config_entry.options[CONF_TRACKED_ASSET_PAIRS]) From c3fcd34d4cf94f55dc0d54bf16efa3e2daab8283 Mon Sep 17 00:00:00 2001 From: Joakim Plate Date: Sat, 4 Oct 2025 15:17:56 +0200 Subject: [PATCH 2/8] Fix blue current mocking out platform with empty string (#153604) Co-authored-by: Josef Zweck --- tests/components/blue_current/__init__.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/tests/components/blue_current/__init__.py b/tests/components/blue_current/__init__.py index 420c3bdfdc5be..a5c4f7ff82a80 100644 --- a/tests/components/blue_current/__init__.py +++ b/tests/components/blue_current/__init__.py @@ -120,7 +120,7 @@ async def update_charge_point( async def init_integration( hass: HomeAssistant, config_entry: MockConfigEntry, - platform="", + platform: str | None = None, charge_point: dict | None = None, status: dict | None = None, grid: dict | None = None, @@ -136,6 +136,10 @@ async def init_integration( if grid is None: grid = {} + platforms = [platform] if platform else [] + if platform: + platforms.append(platform) + future_container = FutureContainer(hass.loop.create_future()) started_loop = Event() @@ -144,7 +148,7 @@ async def init_integration( ) with ( - patch("homeassistant.components.blue_current.PLATFORMS", [platform]), + patch("homeassistant.components.blue_current.PLATFORMS", platforms), patch("homeassistant.components.blue_current.Client", return_value=client_mock), ): config_entry.add_to_hass(hass) From d97c1f0fc3ba81f6f9f56680960c034fb55732c5 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Sat, 4 Oct 2025 16:21:16 +0200 Subject: [PATCH 3/8] Update grpcio to 1.75.1 (#153643) --- homeassistant/package_constraints.txt | 6 +++--- script/gen_requirements_all.py | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 34fe8d2d2bf0c..06fe35f7f9127 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -88,9 +88,9 @@ httplib2>=0.19.0 # gRPC is an implicit dependency that we want to make explicit so we manage # upgrades intentionally. It is a large package to build from source and we # want to ensure we have wheels built. -grpcio==1.72.1 -grpcio-status==1.72.1 -grpcio-reflection==1.72.1 +grpcio==1.75.1 +grpcio-status==1.75.1 +grpcio-reflection==1.75.1 # This is a old unmaintained library and is replaced with pycryptodome pycrypto==1000000000.0.0 diff --git a/script/gen_requirements_all.py b/script/gen_requirements_all.py index 5d176adfdec06..bdd8ed2cda15b 100755 --- a/script/gen_requirements_all.py +++ b/script/gen_requirements_all.py @@ -113,9 +113,9 @@ # gRPC is an implicit dependency that we want to make explicit so we manage # upgrades intentionally. It is a large package to build from source and we # want to ensure we have wheels built. -grpcio==1.72.1 -grpcio-status==1.72.1 -grpcio-reflection==1.72.1 +grpcio==1.75.1 +grpcio-status==1.75.1 +grpcio-reflection==1.75.1 # This is a old unmaintained library and is replaced with pycryptodome pycrypto==1000000000.0.0 From 768a505904202d8242616267c817e2cf41550b5a Mon Sep 17 00:00:00 2001 From: Manu <4445816+tr4nt0r@users.noreply.github.com> Date: Sat, 4 Oct 2025 16:23:27 +0200 Subject: [PATCH 4/8] Add translations and icons to OralB integration (#153605) --- homeassistant/components/oralb/icons.json | 61 +++++++++++++++++++++ homeassistant/components/oralb/sensor.py | 29 +++++++++- homeassistant/components/oralb/strings.json | 52 +++++++++++++++++- tests/components/oralb/test_sensor.py | 4 +- 4 files changed, 140 insertions(+), 6 deletions(-) create mode 100644 homeassistant/components/oralb/icons.json diff --git a/homeassistant/components/oralb/icons.json b/homeassistant/components/oralb/icons.json new file mode 100644 index 0000000000000..ec8426d28a0f8 --- /dev/null +++ b/homeassistant/components/oralb/icons.json @@ -0,0 +1,61 @@ +{ + "entity": { + "sensor": { + "pressure": { + "default": "mdi:tooth-outline", + "state": { + "high": "mdi:tooth", + "low": "mdi:alert", + "power_button_pressed": "mdi:power", + "button_pressed": "mdi:radiobox-marked" + } + }, + "sector": { + "default": "mdi:circle-outline", + "state": { + "sector_1": "mdi:circle-slice-2", + "sector_2": "mdi:circle-slice-4", + "sector_3": "mdi:circle-slice-6", + "sector_4": "mdi:circle-slice-8", + "success": "mdi:check-circle-outline" + } + }, + "toothbrush_state": { + "default": "mdi:toothbrush-electric", + "state": { + "initializing": "mdi:sync", + "idle": "mdi:toothbrush-electric", + "running": "mdi:waveform", + "charging": "mdi:battery-charging", + "setup": "mdi:wrench", + "flight_menu": "mdi:airplane", + "selection_menu": "mdi:menu", + "off": "mdi:power", + "sleeping": "mdi:sleep", + "transport": "mdi:dolly" + } + }, + "number_of_sectors": { + "default": "mdi:chart-pie" + }, + "mode": { + "default": "mdi:toothbrush-paste", + "state": { + "daily_clean": "mdi:repeat-once", + "sensitive": "mdi:feather", + "gum_care": "mdi:tooth-outline", + "intense": "mdi:shape-circle-plus", + "whitening": "mdi:shimmer", + "whiten": "mdi:shimmer", + "tongue_cleaning": "mdi:gate-and", + "super_sensitive": "mdi:feather", + "massage": "mdi:spa", + "deep_clean": "mdi:water", + "turbo": "mdi:car-turbocharger", + "off": "mdi:power", + "settings": "mdi:cog-outline" + } + } + } + } +} diff --git a/homeassistant/components/oralb/sensor.py b/homeassistant/components/oralb/sensor.py index 3b345f4b36aa3..17d68a6aaab6e 100644 --- a/homeassistant/components/oralb/sensor.py +++ b/homeassistant/components/oralb/sensor.py @@ -3,6 +3,13 @@ from __future__ import annotations from oralb_ble import OralBSensor, SensorUpdate +from oralb_ble.parser import ( + IO_SERIES_MODES, + PRESSURE, + SECTOR_MAP, + SMART_SERIES_MODES, + STATES, +) from homeassistant.components.bluetooth.passive_update_processor import ( PassiveBluetoothDataProcessor, @@ -39,6 +46,8 @@ key=OralBSensor.SECTOR, translation_key="sector", entity_category=EntityCategory.DIAGNOSTIC, + options=[v.replace(" ", "_") for v in set(SECTOR_MAP.values()) | {"no_sector"}], + device_class=SensorDeviceClass.ENUM, ), OralBSensor.NUMBER_OF_SECTORS: SensorEntityDescription( key=OralBSensor.NUMBER_OF_SECTORS, @@ -53,16 +62,26 @@ ), OralBSensor.TOOTHBRUSH_STATE: SensorEntityDescription( key=OralBSensor.TOOTHBRUSH_STATE, + translation_key="toothbrush_state", + options=[v.replace(" ", "_") for v in set(STATES.values())], + device_class=SensorDeviceClass.ENUM, name=None, ), OralBSensor.PRESSURE: SensorEntityDescription( key=OralBSensor.PRESSURE, translation_key="pressure", + options=[v.replace(" ", "_") for v in set(PRESSURE.values()) | {"low"}], + device_class=SensorDeviceClass.ENUM, ), OralBSensor.MODE: SensorEntityDescription( key=OralBSensor.MODE, translation_key="mode", entity_category=EntityCategory.DIAGNOSTIC, + options=[ + v.replace(" ", "_") + for v in set(IO_SERIES_MODES.values()) | set(SMART_SERIES_MODES.values()) + ], + device_class=SensorDeviceClass.ENUM, ), OralBSensor.SIGNAL_STRENGTH: SensorEntityDescription( key=OralBSensor.SIGNAL_STRENGTH, @@ -134,7 +153,15 @@ class OralBBluetoothSensorEntity( @property def native_value(self) -> str | int | None: """Return the native value.""" - return self.processor.entity_data.get(self.entity_key) + value = self.processor.entity_data.get(self.entity_key) + if isinstance(value, str): + value = value.replace(" ", "_") + if ( + self.entity_description.options is not None + and value not in self.entity_description.options + ): # append unknown values to enum + self.entity_description.options.append(value) + return value @property def available(self) -> bool: diff --git a/homeassistant/components/oralb/strings.json b/homeassistant/components/oralb/strings.json index 775bbedac74f0..db3b8de5965e7 100644 --- a/homeassistant/components/oralb/strings.json +++ b/homeassistant/components/oralb/strings.json @@ -22,7 +22,15 @@ "entity": { "sensor": { "sector": { - "name": "Sector" + "name": "Sector", + "state": { + "no_sector": "No sector", + "sector_1": "Sector 1", + "sector_2": "Sector 2", + "sector_3": "Sector 3", + "sector_4": "Sector 4", + "success": "Success" + } }, "number_of_sectors": { "name": "Number of sectors" @@ -31,10 +39,48 @@ "name": "Sector timer" }, "pressure": { - "name": "Pressure" + "name": "Pressure", + "state": { + "normal": "[%key:common::state::normal%]", + "high": "[%key:common::state::high%]", + "low": "[%key:common::state::low%]", + "power_button_pressed": "Power button pressed", + "button_pressed": "Button pressed" + } }, "mode": { - "name": "Brushing mode" + "name": "Brushing mode", + "state": { + "daily_clean": "Daily clean", + "sensitive": "Sensitive", + "gum_care": "Gum care", + "intense": "Intense", + "whitening": "Whiten", + "whiten": "[%key:component::oralb::entity::sensor::mode::state::whitening%]", + "tongue_cleaning": "Tongue clean", + "super_sensitive": "Super sensitive", + "massage": "Massage", + "deep_clean": "Deep clean", + "turbo": "Turbo", + "off": "[%key:common::state::off%]", + "settings": "Settings" + } + }, + "toothbrush_state": { + "state": { + "initializing": "Initializing", + "idle": "[%key:common::state::idle%]", + "running": "Running", + "charging": "[%key:common::state::charging%]", + "setup": "Setup", + "flight_menu": "Flight menu", + "selection_menu": "Selection menu", + "off": "[%key:common::state::off%]", + "sleeping": "Sleeping", + "transport": "Transport", + "final_test": "Final test", + "pcb_test": "PCB test" + } } } } diff --git a/tests/components/oralb/test_sensor.py b/tests/components/oralb/test_sensor.py index 147f20733d6f2..8c2c11c7dbcad 100644 --- a/tests/components/oralb/test_sensor.py +++ b/tests/components/oralb/test_sensor.py @@ -101,7 +101,7 @@ async def test_sensors_io_series_4(hass: HomeAssistant) -> None: toothbrush_sensor = hass.states.get("sensor.io_series_4_48be_brushing_mode") toothbrush_sensor_attrs = toothbrush_sensor.attributes - assert toothbrush_sensor.state == "gum care" + assert toothbrush_sensor.state == "gum_care" assert ( toothbrush_sensor_attrs[ATTR_FRIENDLY_NAME] == "IO Series 4 48BE Brushing mode" ) @@ -133,7 +133,7 @@ async def test_sensors_io_series_4(hass: HomeAssistant) -> None: toothbrush_sensor = hass.states.get("sensor.io_series_4_48be_brushing_mode") # Sleepy devices should keep their state over time - assert toothbrush_sensor.state == "gum care" + assert toothbrush_sensor.state == "gum_care" toothbrush_sensor_attrs = toothbrush_sensor.attributes assert toothbrush_sensor_attrs[ATTR_ASSUMED_STATE] is True From bd87a3aa4d8d804f0ffc3f5a7ebd4022878dd011 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Sat, 4 Oct 2025 16:27:22 +0200 Subject: [PATCH 5/8] Update PyYAML to 6.0.3 (#153626) --- homeassistant/package_constraints.txt | 2 +- pyproject.toml | 2 +- requirements.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 06fe35f7f9127..afb46240776b4 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -60,7 +60,7 @@ pyserial==3.5 pyspeex-noise==1.0.2 python-slugify==8.0.4 PyTurboJPEG==1.8.0 -PyYAML==6.0.2 +PyYAML==6.0.3 requests==2.32.5 securetar==2025.2.1 SQLAlchemy==2.0.41 diff --git a/pyproject.toml b/pyproject.toml index d33177e12760b..14d15e4675f0d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -65,7 +65,7 @@ dependencies = [ "packaging>=23.1", "psutil-home-assistant==0.0.1", "python-slugify==8.0.4", - "PyYAML==6.0.2", + "PyYAML==6.0.3", "requests==2.32.5", "securetar==2025.2.1", "SQLAlchemy==2.0.41", diff --git a/requirements.txt b/requirements.txt index a1c4900b4007b..c7e63873f9c98 100644 --- a/requirements.txt +++ b/requirements.txt @@ -37,7 +37,7 @@ orjson==3.11.3 packaging>=23.1 psutil-home-assistant==0.0.1 python-slugify==8.0.4 -PyYAML==6.0.2 +PyYAML==6.0.3 requests==2.32.5 securetar==2025.2.1 SQLAlchemy==2.0.41 From 8985527a872e4fe86c4d53605ee293d0f53bc985 Mon Sep 17 00:00:00 2001 From: Kevin McCormack Date: Sat, 4 Oct 2025 10:34:37 -0400 Subject: [PATCH 6/8] Bump libpyvivotek to 0.6.1 and add strict typing for Vivotek integration (#153342) Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .strict-typing | 1 + homeassistant/components/vivotek/manifest.json | 2 +- mypy.ini | 10 ++++++++++ requirements_all.txt | 2 +- 4 files changed, 13 insertions(+), 2 deletions(-) diff --git a/.strict-typing b/.strict-typing index e950da8d25d0b..291c3d78e678b 100644 --- a/.strict-typing +++ b/.strict-typing @@ -555,6 +555,7 @@ homeassistant.components.vacuum.* homeassistant.components.vallox.* homeassistant.components.valve.* homeassistant.components.velbus.* +homeassistant.components.vivotek.* homeassistant.components.vlc_telnet.* homeassistant.components.vodafone_station.* homeassistant.components.volvo.* diff --git a/homeassistant/components/vivotek/manifest.json b/homeassistant/components/vivotek/manifest.json index f0b622afcadaf..74a8bf9b75047 100644 --- a/homeassistant/components/vivotek/manifest.json +++ b/homeassistant/components/vivotek/manifest.json @@ -6,5 +6,5 @@ "iot_class": "local_polling", "loggers": ["libpyvivotek"], "quality_scale": "legacy", - "requirements": ["libpyvivotek==0.4.0"] + "requirements": ["libpyvivotek==0.6.1"] } diff --git a/mypy.ini b/mypy.ini index 1813576cf23fc..81776140629a2 100644 --- a/mypy.ini +++ b/mypy.ini @@ -5309,6 +5309,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.vivotek.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + [mypy-homeassistant.components.vlc_telnet.*] check_untyped_defs = true disallow_incomplete_defs = true diff --git a/requirements_all.txt b/requirements_all.txt index 9ceeded4b4325..aafe1e33e05a4 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1358,7 +1358,7 @@ letpot==0.6.2 libpyfoscamcgi==0.0.7 # homeassistant.components.vivotek -libpyvivotek==0.4.0 +libpyvivotek==0.6.1 # homeassistant.components.libre_hardware_monitor librehardwaremonitor-api==1.4.0 From 34f6ead7a114b071dbccfd529a53a1b0d0991053 Mon Sep 17 00:00:00 2001 From: Hessel Date: Sat, 4 Oct 2025 16:38:11 +0200 Subject: [PATCH 7/8] Wallbox fix Rate Limit issue for multiple chargers (#153074) --- homeassistant/components/wallbox/const.py | 2 +- homeassistant/components/wallbox/coordinator.py | 7 ++++++- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/wallbox/const.py b/homeassistant/components/wallbox/const.py index 1059a41db536f..cbe1aaa912a9d 100644 --- a/homeassistant/components/wallbox/const.py +++ b/homeassistant/components/wallbox/const.py @@ -3,7 +3,7 @@ from enum import StrEnum DOMAIN = "wallbox" -UPDATE_INTERVAL = 60 +UPDATE_INTERVAL = 90 BIDIRECTIONAL_MODEL_PREFIXES = ["QS"] diff --git a/homeassistant/components/wallbox/coordinator.py b/homeassistant/components/wallbox/coordinator.py index 4e743b2106b74..36785ee362a93 100644 --- a/homeassistant/components/wallbox/coordinator.py +++ b/homeassistant/components/wallbox/coordinator.py @@ -209,7 +209,12 @@ def _get_data(self) -> dict[str, Any]: ) from wallbox_connection_error async def _async_update_data(self) -> dict[str, Any]: - """Get new sensor data for Wallbox component.""" + """Get new sensor data for Wallbox component. Set update interval to be UPDATE_INTERVAL * #wallbox chargers configured, this is necessary due to rate limitations.""" + + self.update_interval = timedelta( + seconds=UPDATE_INTERVAL + * max(len(self.hass.config_entries.async_loaded_entries(DOMAIN)), 1) + ) return await self.hass.async_add_executor_job(self._get_data) @_require_authentication From 6f89fe81cc131633162565acb553609afd0d0e1a Mon Sep 17 00:00:00 2001 From: Manu <4445816+tr4nt0r@users.noreply.github.com> Date: Sat, 4 Oct 2025 16:42:28 +0200 Subject: [PATCH 8/8] Remove Plum Lightpad integration (#153590) --- CODEOWNERS | 2 - .../components/plum_lightpad/__init__.py | 64 +++--- .../components/plum_lightpad/config_flow.py | 51 +---- .../components/plum_lightpad/const.py | 3 - .../components/plum_lightpad/icons.json | 9 - .../components/plum_lightpad/light.py | 201 ------------------ .../components/plum_lightpad/manifest.json | 7 +- .../components/plum_lightpad/strings.json | 18 +- .../components/plum_lightpad/utils.py | 14 -- homeassistant/generated/config_flows.py | 1 - homeassistant/generated/integrations.json | 6 - requirements_all.txt | 3 - requirements_test_all.txt | 3 - .../plum_lightpad/test_config_flow.py | 90 -------- tests/components/plum_lightpad/test_init.py | 108 +++------- 15 files changed, 67 insertions(+), 513 deletions(-) delete mode 100644 homeassistant/components/plum_lightpad/const.py delete mode 100644 homeassistant/components/plum_lightpad/icons.json delete mode 100644 homeassistant/components/plum_lightpad/light.py delete mode 100644 homeassistant/components/plum_lightpad/utils.py delete mode 100644 tests/components/plum_lightpad/test_config_flow.py diff --git a/CODEOWNERS b/CODEOWNERS index ccd8cbadb6bdf..3235a5b73dff0 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1196,8 +1196,6 @@ build.json @home-assistant/supervisor /tests/components/plex/ @jjlawren /homeassistant/components/plugwise/ @CoMPaTech @bouwew /tests/components/plugwise/ @CoMPaTech @bouwew -/homeassistant/components/plum_lightpad/ @ColinHarrington @prystupa -/tests/components/plum_lightpad/ @ColinHarrington @prystupa /homeassistant/components/point/ @fredrike /tests/components/point/ @fredrike /homeassistant/components/pooldose/ @lmaertin diff --git a/homeassistant/components/plum_lightpad/__init__.py b/homeassistant/components/plum_lightpad/__init__.py index f1816f03d3b6f..831f50b1a9ee6 100644 --- a/homeassistant/components/plum_lightpad/__init__.py +++ b/homeassistant/components/plum_lightpad/__init__.py @@ -1,52 +1,36 @@ """Support for Plum Lightpad devices.""" -import logging - -from aiohttp import ContentTypeError -from requests.exceptions import ConnectTimeout, HTTPError - -from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ( - CONF_PASSWORD, - CONF_USERNAME, - EVENT_HOMEASSISTANT_STOP, - Platform, -) +from homeassistant.config_entries import ConfigEntry, ConfigEntryState from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers import issue_registry as ir -from .const import DOMAIN -from .utils import load_plum +DOMAIN = "plum_lightpad" -_LOGGER = logging.getLogger(__name__) -PLATFORMS = [Platform.LIGHT] - - -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, _: ConfigEntry) -> bool: """Set up Plum Lightpad from a config entry.""" - _LOGGER.debug("Setting up config entry with ID = %s", entry.unique_id) - - username = entry.data[CONF_USERNAME] - password = entry.data[CONF_PASSWORD] + ir.async_create_issue( + hass, + DOMAIN, + DOMAIN, + is_fixable=False, + severity=ir.IssueSeverity.ERROR, + translation_key="integration_removed", + translation_placeholders={ + "entries": "/config/integrations/integration/plum_lightpad", + }, + ) - try: - plum = await load_plum(username, password, hass) - except ContentTypeError as ex: - _LOGGER.error("Unable to authenticate to Plum cloud: %s", ex) - return False - except (ConnectTimeout, HTTPError) as ex: - _LOGGER.error("Unable to connect to Plum cloud: %s", ex) - raise ConfigEntryNotReady from ex - - hass.data.setdefault(DOMAIN, {}) - hass.data[DOMAIN][entry.entry_id] = plum + return True - await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) - def cleanup(event): - """Clean up resources.""" - plum.cleanup() +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload 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) - entry.async_on_unload(hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, cleanup)) return True diff --git a/homeassistant/components/plum_lightpad/config_flow.py b/homeassistant/components/plum_lightpad/config_flow.py index 2a929d14c9e58..4a0b849d9397e 100644 --- a/homeassistant/components/plum_lightpad/config_flow.py +++ b/homeassistant/components/plum_lightpad/config_flow.py @@ -2,59 +2,12 @@ from __future__ import annotations -import logging -from typing import Any +from homeassistant.config_entries import ConfigFlow -from aiohttp import ContentTypeError -from requests.exceptions import ConnectTimeout, HTTPError -import voluptuous as vol - -from homeassistant.config_entries import ConfigFlow, ConfigFlowResult -from homeassistant.const import CONF_PASSWORD, CONF_USERNAME - -from .const import DOMAIN -from .utils import load_plum - -_LOGGER = logging.getLogger(__name__) +from . import DOMAIN class PlumLightpadConfigFlow(ConfigFlow, domain=DOMAIN): """Config flow for Plum Lightpad integration.""" VERSION = 1 - - def _show_form(self, errors=None): - schema = { - vol.Required(CONF_USERNAME): str, - vol.Required(CONF_PASSWORD): str, - } - - return self.async_show_form( - step_id="user", - data_schema=vol.Schema(schema), - errors=errors or {}, - ) - - async def async_step_user( - self, user_input: dict[str, Any] | None = None - ) -> ConfigFlowResult: - """Handle a flow initialized by the user or redirected to by import.""" - if not user_input: - return self._show_form() - - username = user_input[CONF_USERNAME] - password = user_input[CONF_PASSWORD] - - # load Plum just so we know username/password work - try: - await load_plum(username, password, self.hass) - except (ContentTypeError, ConnectTimeout, HTTPError) as ex: - _LOGGER.error("Unable to connect/authenticate to Plum cloud: %s", str(ex)) - return self._show_form({"base": "cannot_connect"}) - - await self.async_set_unique_id(username) - self._abort_if_unique_id_configured() - - return self.async_create_entry( - title=username, data={CONF_USERNAME: username, CONF_PASSWORD: password} - ) diff --git a/homeassistant/components/plum_lightpad/const.py b/homeassistant/components/plum_lightpad/const.py deleted file mode 100644 index efea35d0a7a93..0000000000000 --- a/homeassistant/components/plum_lightpad/const.py +++ /dev/null @@ -1,3 +0,0 @@ -"""Constants for the Plum Lightpad component.""" - -DOMAIN = "plum_lightpad" diff --git a/homeassistant/components/plum_lightpad/icons.json b/homeassistant/components/plum_lightpad/icons.json deleted file mode 100644 index dd65160e4744e..0000000000000 --- a/homeassistant/components/plum_lightpad/icons.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "entity": { - "light": { - "glow_ring": { - "default": "mdi:crop-portrait" - } - } - } -} diff --git a/homeassistant/components/plum_lightpad/light.py b/homeassistant/components/plum_lightpad/light.py deleted file mode 100644 index 78743c1280847..0000000000000 --- a/homeassistant/components/plum_lightpad/light.py +++ /dev/null @@ -1,201 +0,0 @@ -"""Support for Plum Lightpad lights.""" - -from __future__ import annotations - -from typing import Any - -from plumlightpad import Plum - -from homeassistant.components.light import ( - ATTR_BRIGHTNESS, - ATTR_HS_COLOR, - ColorMode, - LightEntity, -) -from homeassistant.config_entries import ConfigEntry -from homeassistant.core import HomeAssistant -from homeassistant.helpers.aiohttp_client import async_get_clientsession -from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from homeassistant.util import color as color_util - -from .const import DOMAIN - - -async def async_setup_entry( - hass: HomeAssistant, - entry: ConfigEntry, - async_add_entities: AddConfigEntryEntitiesCallback, -) -> None: - """Set up Plum Lightpad dimmer lights and glow rings.""" - - plum: Plum = hass.data[DOMAIN][entry.entry_id] - - def setup_entities(device) -> None: - entities: list[LightEntity] = [] - - if "lpid" in device: - lightpad = plum.get_lightpad(device["lpid"]) - entities.append(GlowRing(lightpad=lightpad)) - - if "llid" in device: - logical_load = plum.get_load(device["llid"]) - entities.append(PlumLight(load=logical_load)) - - async_add_entities(entities) - - async def new_load(device): - setup_entities(device) - - async def new_lightpad(device): - setup_entities(device) - - device_web_session = async_get_clientsession(hass, verify_ssl=False) - entry.async_create_background_task( - hass, - plum.discover( - hass.loop, - loadListener=new_load, - lightpadListener=new_lightpad, - websession=device_web_session, - ), - "plum.light-discover", - ) - - -class PlumLight(LightEntity): - """Representation of a Plum Lightpad dimmer.""" - - _attr_should_poll = False - _attr_has_entity_name = True - _attr_name = None - - def __init__(self, load): - """Initialize the light.""" - self._load = load - self._brightness = load.level - unique_id = f"{load.llid}.light" - self._attr_unique_id = unique_id - self._attr_device_info = DeviceInfo( - identifiers={(DOMAIN, unique_id)}, - manufacturer="Plum", - model="Dimmer", - name=load.name, - ) - - async def async_added_to_hass(self) -> None: - """Subscribe to dimmerchange events.""" - self._load.add_event_listener("dimmerchange", self.dimmerchange) - - def dimmerchange(self, event): - """Change event handler updating the brightness.""" - self._brightness = event["level"] - self.schedule_update_ha_state() - - @property - def brightness(self) -> int: - """Return the brightness of this switch between 0..255.""" - return self._brightness - - @property - def is_on(self) -> bool: - """Return true if light is on.""" - return self._brightness > 0 - - @property - def color_mode(self) -> ColorMode: - """Flag supported features.""" - if self._load.dimmable: - return ColorMode.BRIGHTNESS - return ColorMode.ONOFF - - @property - def supported_color_modes(self) -> set[ColorMode]: - """Flag supported color modes.""" - return {self.color_mode} - - async def async_turn_on(self, **kwargs: Any) -> None: - """Turn the light on.""" - if ATTR_BRIGHTNESS in kwargs: - await self._load.turn_on(kwargs[ATTR_BRIGHTNESS]) - else: - await self._load.turn_on() - - async def async_turn_off(self, **kwargs: Any) -> None: - """Turn the light off.""" - await self._load.turn_off() - - -class GlowRing(LightEntity): - """Representation of a Plum Lightpad dimmer glow ring.""" - - _attr_color_mode = ColorMode.HS - _attr_should_poll = False - _attr_translation_key = "glow_ring" - _attr_supported_color_modes = {ColorMode.HS} - - def __init__(self, lightpad): - """Initialize the light.""" - self._lightpad = lightpad - self._attr_name = f"{lightpad.friendly_name} Glow Ring" - - self._attr_is_on = lightpad.glow_enabled - self._glow_intensity = lightpad.glow_intensity - unique_id = f"{self._lightpad.lpid}.glow" - self._attr_unique_id = unique_id - - self._red = lightpad.glow_color["red"] - self._green = lightpad.glow_color["green"] - self._blue = lightpad.glow_color["blue"] - self._attr_device_info = DeviceInfo( - identifiers={(DOMAIN, unique_id)}, - manufacturer="Plum", - model="Glow Ring", - name=self._attr_name, - ) - - async def async_added_to_hass(self) -> None: - """Subscribe to configchange events.""" - self._lightpad.add_event_listener("configchange", self.configchange_event) - - def configchange_event(self, event): - """Handle Configuration change event.""" - config = event["changes"] - - self._attr_is_on = config["glowEnabled"] - self._glow_intensity = config["glowIntensity"] - - self._red = config["glowColor"]["red"] - self._green = config["glowColor"]["green"] - self._blue = config["glowColor"]["blue"] - self.schedule_update_ha_state() - - @property - def hs_color(self): - """Return the hue and saturation color value [float, float].""" - return color_util.color_RGB_to_hs(self._red, self._green, self._blue) - - @property - def brightness(self) -> int: - """Return the brightness of this switch between 0..255.""" - return min(max(int(round(self._glow_intensity * 255, 0)), 0), 255) - - async def async_turn_on(self, **kwargs: Any) -> None: - """Turn the light on.""" - if ATTR_BRIGHTNESS in kwargs: - brightness_pct = kwargs[ATTR_BRIGHTNESS] / 255.0 - await self._lightpad.set_config({"glowIntensity": brightness_pct}) - elif ATTR_HS_COLOR in kwargs: - hs_color = kwargs[ATTR_HS_COLOR] - red, green, blue = color_util.color_hs_to_RGB(*hs_color) - await self._lightpad.set_glow_color(red, green, blue, 0) - else: - await self._lightpad.set_config({"glowEnabled": True}) - - async def async_turn_off(self, **kwargs: Any) -> None: - """Turn the light off.""" - if ATTR_BRIGHTNESS in kwargs: - brightness_pct = kwargs[ATTR_BRIGHTNESS] / 255.0 - await self._lightpad.set_config({"glowIntensity": brightness_pct}) - else: - await self._lightpad.set_config({"glowEnabled": False}) diff --git a/homeassistant/components/plum_lightpad/manifest.json b/homeassistant/components/plum_lightpad/manifest.json index ffe2b47a0c626..eee716d77e3bc 100644 --- a/homeassistant/components/plum_lightpad/manifest.json +++ b/homeassistant/components/plum_lightpad/manifest.json @@ -1,10 +1,9 @@ { "domain": "plum_lightpad", "name": "Plum Lightpad", - "codeowners": ["@ColinHarrington", "@prystupa"], - "config_flow": true, + "codeowners": [], "documentation": "https://www.home-assistant.io/integrations/plum_lightpad", + "integration_type": "system", "iot_class": "local_push", - "loggers": ["plumlightpad"], - "requirements": ["plumlightpad==0.0.11"] + "requirements": [] } diff --git a/homeassistant/components/plum_lightpad/strings.json b/homeassistant/components/plum_lightpad/strings.json index 935e161469602..d0268287d4770 100644 --- a/homeassistant/components/plum_lightpad/strings.json +++ b/homeassistant/components/plum_lightpad/strings.json @@ -1,18 +1,8 @@ { - "config": { - "step": { - "user": { - "data": { - "username": "[%key:common::config_flow::data::email%]", - "password": "[%key:common::config_flow::data::password%]" - } - } - }, - "error": { - "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]" - }, - "abort": { - "already_configured": "[%key:common::config_flow::abort::already_configured_account%]" + "issues": { + "integration_removed": { + "title": "The Plum Lightpad integration has been removed", + "description": "The Plum Lightpad integration has been removed from Home Assistant.\n\nThe required cloud services are no longer available since the Plum servers have been shut down. To resolve this issue, please remove the (now defunct) integration entries from your Home Assistant setup. [Click here to see your existing Plum Lightpad integration entries]({entries})." } } } diff --git a/homeassistant/components/plum_lightpad/utils.py b/homeassistant/components/plum_lightpad/utils.py deleted file mode 100644 index 6704b443d7284..0000000000000 --- a/homeassistant/components/plum_lightpad/utils.py +++ /dev/null @@ -1,14 +0,0 @@ -"""Reusable utilities for the Plum Lightpad component.""" - -from plumlightpad import Plum - -from homeassistant.core import HomeAssistant -from homeassistant.helpers.aiohttp_client import async_get_clientsession - - -async def load_plum(username: str, password: str, hass: HomeAssistant) -> Plum: - """Initialize Plum Lightpad API and load metadata stored in the cloud.""" - plum = Plum(username, password) - cloud_web_session = async_get_clientsession(hass, verify_ssl=True) - await plum.loadCloudData(cloud_web_session) - return plum diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 8c162a7f10f7a..dbd749370ca72 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -499,7 +499,6 @@ "playstation_network", "plex", "plugwise", - "plum_lightpad", "point", "pooldose", "poolsense", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index bd3cd7692c990..4f49dad82dcfb 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -5087,12 +5087,6 @@ "config_flow": true, "iot_class": "local_polling" }, - "plum_lightpad": { - "name": "Plum Lightpad", - "integration_type": "hub", - "config_flow": true, - "iot_class": "local_push" - }, "pocketcasts": { "name": "Pocket Casts", "integration_type": "hub", diff --git a/requirements_all.txt b/requirements_all.txt index aafe1e33e05a4..46d68538f39cf 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1722,9 +1722,6 @@ plexwebsocket==0.0.14 # homeassistant.components.plugwise plugwise==1.7.8 -# homeassistant.components.plum_lightpad -plumlightpad==0.0.11 - # homeassistant.components.serial_pm pmsensor==0.4 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index b585352ff22e0..38d599d87f3bc 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1463,9 +1463,6 @@ plexwebsocket==0.0.14 # homeassistant.components.plugwise plugwise==1.7.8 -# homeassistant.components.plum_lightpad -plumlightpad==0.0.11 - # homeassistant.components.poolsense poolsense==0.0.8 diff --git a/tests/components/plum_lightpad/test_config_flow.py b/tests/components/plum_lightpad/test_config_flow.py deleted file mode 100644 index ca7c110c963ec..0000000000000 --- a/tests/components/plum_lightpad/test_config_flow.py +++ /dev/null @@ -1,90 +0,0 @@ -"""Test the Plum Lightpad config flow.""" - -from unittest.mock import patch - -from requests.exceptions import ConnectTimeout - -from homeassistant import config_entries -from homeassistant.components.plum_lightpad.const import DOMAIN -from homeassistant.core import HomeAssistant -from homeassistant.data_entry_flow import FlowResultType - -from tests.common import MockConfigEntry - - -async def test_form(hass: HomeAssistant) -> None: - """Test we get the form.""" - - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} - ) - assert result["type"] is FlowResultType.FORM - assert result["errors"] == {} - - with ( - patch("homeassistant.components.plum_lightpad.utils.Plum.loadCloudData"), - patch( - "homeassistant.components.plum_lightpad.async_setup_entry", - return_value=True, - ) as mock_setup_entry, - ): - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - {"username": "test-plum-username", "password": "test-plum-password"}, - ) - await hass.async_block_till_done() - - assert result2["type"] is FlowResultType.CREATE_ENTRY - assert result2["title"] == "test-plum-username" - assert result2["data"] == { - "username": "test-plum-username", - "password": "test-plum-password", - } - assert len(mock_setup_entry.mock_calls) == 1 - - -async def test_form_cannot_connect(hass: HomeAssistant) -> None: - """Test we handle invalid auth.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} - ) - - with patch( - "homeassistant.components.plum_lightpad.utils.Plum.loadCloudData", - side_effect=ConnectTimeout, - ): - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - {"username": "test-plum-username", "password": "test-plum-password"}, - ) - - assert result2["type"] is FlowResultType.FORM - assert result2["errors"] == {"base": "cannot_connect"} - - -async def test_form_one_entry_per_email_allowed(hass: HomeAssistant) -> None: - """Test that only one entry allowed per Plum cloud email address.""" - MockConfigEntry( - domain=DOMAIN, - unique_id="test-plum-username", - data={"username": "test-plum-username", "password": "test-plum-password"}, - ).add_to_hass(hass) - - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} - ) - - with ( - patch("homeassistant.components.plum_lightpad.utils.Plum.loadCloudData"), - patch( - "homeassistant.components.plum_lightpad.async_setup_entry" - ) as mock_setup_entry, - ): - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - {"username": "test-plum-username", "password": "test-plum-password"}, - ) - - assert result2["type"] is FlowResultType.ABORT - await hass.async_block_till_done() - assert len(mock_setup_entry.mock_calls) == 0 diff --git a/tests/components/plum_lightpad/test_init.py b/tests/components/plum_lightpad/test_init.py index c34ecfd8deb31..09a140016a277 100644 --- a/tests/components/plum_lightpad/test_init.py +++ b/tests/components/plum_lightpad/test_init.py @@ -1,91 +1,51 @@ """Tests for the Plum Lightpad config flow.""" -from unittest.mock import Mock, patch - -from aiohttp import ContentTypeError -from requests.exceptions import HTTPError - -from homeassistant.components.plum_lightpad.const import DOMAIN +from homeassistant.components.plum_lightpad import DOMAIN +from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant -from homeassistant.setup import async_setup_component +from homeassistant.helpers import issue_registry as ir from tests.common import MockConfigEntry -async def test_async_setup_no_domain_config(hass: HomeAssistant) -> None: - """Test setup without configuration is noop.""" - result = await async_setup_component(hass, DOMAIN, {}) - - assert result is True - assert DOMAIN not in hass.data +async def test_repair_issue( + hass: HomeAssistant, issue_registry: ir.IssueRegistry +) -> None: + """Test Plum Lightpad repair issue.""" - -async def test_async_setup_entry_sets_up_light(hass: HomeAssistant) -> None: - """Test that configuring entry sets up light domain.""" - config_entry = MockConfigEntry( + config_entry_1 = MockConfigEntry( + title="Example 1", domain=DOMAIN, - data={"username": "test-plum-username", "password": "test-plum-password"}, ) - config_entry.add_to_hass(hass) - - with ( - patch( - "homeassistant.components.plum_lightpad.utils.Plum.loadCloudData" - ) as mock_loadCloudData, - patch( - "homeassistant.components.plum_lightpad.light.async_setup_entry" - ) as mock_light_async_setup_entry, - ): - result = await hass.config_entries.async_setup(config_entry.entry_id) - assert result is True - - await hass.async_block_till_done() - - assert len(mock_loadCloudData.mock_calls) == 1 - assert len(mock_light_async_setup_entry.mock_calls) == 1 - - -async def test_async_setup_entry_handles_auth_error(hass: HomeAssistant) -> None: - """Test that configuring entry handles Plum Cloud authentication error.""" - config_entry = MockConfigEntry( + 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, - data={"username": "test-plum-username", "password": "test-plum-password"}, ) - config_entry.add_to_hass(hass) + config_entry_2.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry_2.entry_id) + await hass.async_block_till_done() - with ( - patch( - "homeassistant.components.plum_lightpad.utils.Plum.loadCloudData", - side_effect=ContentTypeError(Mock(), None), - ), - patch( - "homeassistant.components.plum_lightpad.light.async_setup_entry" - ) as mock_light_async_setup_entry, - ): - result = await hass.config_entries.async_setup(config_entry.entry_id) + assert config_entry_2.state is ConfigEntryState.LOADED + assert issue_registry.async_get_issue(DOMAIN, DOMAIN) - assert result is False - assert len(mock_light_async_setup_entry.mock_calls) == 0 + # 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) -async def test_async_setup_entry_handles_http_error(hass: HomeAssistant) -> None: - """Test that configuring entry handles HTTP error.""" - config_entry = MockConfigEntry( - domain=DOMAIN, - data={"username": "test-plum-username", "password": "test-plum-password"}, - ) - config_entry.add_to_hass(hass) - - with ( - patch( - "homeassistant.components.plum_lightpad.utils.Plum.loadCloudData", - side_effect=HTTPError, - ), - patch( - "homeassistant.components.plum_lightpad.light.async_setup_entry" - ) as mock_light_async_setup_entry, - ): - result = await hass.config_entries.async_setup(config_entry.entry_id) + # Remove the second one + await hass.config_entries.async_remove(config_entry_2.entry_id) + await hass.async_block_till_done() - assert result is False - assert len(mock_light_async_setup_entry.mock_calls) == 0 + 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