From 2f3fbf00b7400a766ee06e3e656ece143be57543 Mon Sep 17 00:00:00 2001 From: Luke Lashley Date: Fri, 3 Oct 2025 12:30:30 -0400 Subject: [PATCH 01/10] Bump python-roborock to 2.50.2 (#153561) --- homeassistant/components/roborock/config_flow.py | 6 +++--- homeassistant/components/roborock/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/roborock/conftest.py | 11 ++++++++++- tests/components/roborock/mock_data.py | 2 +- .../roborock/snapshots/test_diagnostics.ambr | 12 ++++++------ 7 files changed, 23 insertions(+), 14 deletions(-) diff --git a/homeassistant/components/roborock/config_flow.py b/homeassistant/components/roborock/config_flow.py index e5f449d4984768..80b90210bf3dad 100644 --- a/homeassistant/components/roborock/config_flow.py +++ b/homeassistant/components/roborock/config_flow.py @@ -129,7 +129,7 @@ async def async_step_code( reauth_entry, data_updates={CONF_USER_DATA: user_data.as_dict()} ) self._abort_if_unique_id_configured(error="already_configured_account") - return self._create_entry(self._client, self._username, user_data) + return await self._create_entry(self._client, self._username, user_data) return self.async_show_form( step_id="code", @@ -176,7 +176,7 @@ async def async_step_reauth_confirm( return await self.async_step_code() return self.async_show_form(step_id="reauth_confirm", errors=errors) - def _create_entry( + async def _create_entry( self, client: RoborockApiClient, username: str, user_data: UserData ) -> ConfigFlowResult: """Finished config flow and create entry.""" @@ -185,7 +185,7 @@ def _create_entry( data={ CONF_USERNAME: username, CONF_USER_DATA: user_data.as_dict(), - CONF_BASE_URL: client.base_url, + CONF_BASE_URL: await client.base_url, }, ) diff --git a/homeassistant/components/roborock/manifest.json b/homeassistant/components/roborock/manifest.json index e6bf46e2202af8..9339f70576b71a 100644 --- a/homeassistant/components/roborock/manifest.json +++ b/homeassistant/components/roborock/manifest.json @@ -19,7 +19,7 @@ "loggers": ["roborock"], "quality_scale": "silver", "requirements": [ - "python-roborock==2.49.1", + "python-roborock==2.50.2", "vacuum-map-parser-roborock==0.1.4" ] } diff --git a/requirements_all.txt b/requirements_all.txt index cefc93148c875e..66db3f255a4626 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2550,7 +2550,7 @@ python-rabbitair==0.0.8 python-ripple-api==0.0.3 # homeassistant.components.roborock -python-roborock==2.49.1 +python-roborock==2.50.2 # homeassistant.components.smarttub python-smarttub==0.0.44 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 431a13462d0e9b..a21f6af01e7b4a 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2120,7 +2120,7 @@ python-pooldose==0.5.0 python-rabbitair==0.0.8 # homeassistant.components.roborock -python-roborock==2.49.1 +python-roborock==2.50.2 # homeassistant.components.smarttub python-smarttub==0.0.44 diff --git a/tests/components/roborock/conftest.py b/tests/components/roborock/conftest.py index e4731c6e9f23ad..ea569399ace7a4 100644 --- a/tests/components/roborock/conftest.py +++ b/tests/components/roborock/conftest.py @@ -1,11 +1,12 @@ """Global fixtures for Roborock integration.""" +import asyncio from collections.abc import Generator from copy import deepcopy import pathlib import tempfile from typing import Any -from unittest.mock import Mock, patch +from unittest.mock import Mock, PropertyMock, patch import pytest from roborock import RoborockCategory, RoomMapping @@ -70,6 +71,9 @@ async def update_values( @pytest.fixture(name="bypass_api_client_fixture") def bypass_api_client_fixture() -> None: """Skip calls to the API client.""" + base_url_future = asyncio.Future() + base_url_future.set_result(BASE_URL) + with ( patch( "homeassistant.components.roborock.RoborockApiClient.get_home_data_v3", @@ -82,6 +86,11 @@ def bypass_api_client_fixture() -> None: patch( "homeassistant.components.roborock.coordinator.RoborockLocalClientV1.load_multi_map" ), + patch( + "homeassistant.components.roborock.config_flow.RoborockApiClient.base_url", + new_callable=PropertyMock, + return_value=base_url_future, + ), ): yield diff --git a/tests/components/roborock/mock_data.py b/tests/components/roborock/mock_data.py index cf4f167ef7fb42..1495dcb686c674 100644 --- a/tests/components/roborock/mock_data.py +++ b/tests/components/roborock/mock_data.py @@ -61,7 +61,7 @@ MOCK_CONFIG = { CONF_USERNAME: USER_EMAIL, CONF_USER_DATA: USER_DATA.as_dict(), - CONF_BASE_URL: None, + CONF_BASE_URL: BASE_URL, } HOME_DATA_RAW = { diff --git a/tests/components/roborock/snapshots/test_diagnostics.ambr b/tests/components/roborock/snapshots/test_diagnostics.ambr index ed1c37f6fa2d0b..bf7fbfaadc399f 100644 --- a/tests/components/roborock/snapshots/test_diagnostics.ambr +++ b/tests/components/roborock/snapshots/test_diagnostics.ambr @@ -216,9 +216,9 @@ 'squareMeterCleanArea': 1159.2, }), 'consumable': dict({ - 'cleaningBrushTimeLeft': 1079935, + 'cleaningBrushTimeLeft': 235, 'cleaningBrushWorkTimes': 65, - 'dustCollectionTimeLeft': 80975, + 'dustCollectionTimeLeft': 65, 'dustCollectionWorkTimes': 25, 'filterElementWorkTime': 0, 'filterTimeLeft': 465618, @@ -229,7 +229,7 @@ 'sensorTimeLeft': 33618, 'sideBrushTimeLeft': 645618, 'sideBrushWorkTime': 74382, - 'strainerTimeLeft': 539935, + 'strainerTimeLeft': 85, 'strainerWorkTimes': 65, }), 'lastCleanRecord': dict({ @@ -501,9 +501,9 @@ 'squareMeterCleanArea': 1159.2, }), 'consumable': dict({ - 'cleaningBrushTimeLeft': 1079935, + 'cleaningBrushTimeLeft': 235, 'cleaningBrushWorkTimes': 65, - 'dustCollectionTimeLeft': 80975, + 'dustCollectionTimeLeft': 65, 'dustCollectionWorkTimes': 25, 'filterElementWorkTime': 0, 'filterTimeLeft': 465618, @@ -514,7 +514,7 @@ 'sensorTimeLeft': 33618, 'sideBrushTimeLeft': 645618, 'sideBrushWorkTime': 74382, - 'strainerTimeLeft': 539935, + 'strainerTimeLeft': 85, 'strainerWorkTimes': 65, }), 'lastCleanRecord': dict({ From 3f9421ab0801a339e62506c0c123066c53810efb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ab=C3=ADlio=20Costa?= Date: Fri, 3 Oct 2025 17:39:14 +0100 Subject: [PATCH 02/10] Debounce updates in Idasen Desk (#153503) --- .../components/idasen_desk/coordinator.py | 26 +++++++++++++++++-- tests/components/idasen_desk/__init__.py | 2 ++ tests/components/idasen_desk/test_cover.py | 17 ++++++++---- tests/components/idasen_desk/test_sensor.py | 22 ++++++++++++++-- 4 files changed, 58 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/idasen_desk/coordinator.py b/homeassistant/components/idasen_desk/coordinator.py index 5da3d57cf9a6f3..f7b7edd2cc1a7a 100644 --- a/homeassistant/components/idasen_desk/coordinator.py +++ b/homeassistant/components/idasen_desk/coordinator.py @@ -8,13 +8,16 @@ from homeassistant.components import bluetooth from homeassistant.config_entries import ConfigEntry -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.debounce import Debouncer from homeassistant.helpers.update_coordinator import DataUpdateCoordinator _LOGGER = logging.getLogger(__name__) type IdasenDeskConfigEntry = ConfigEntry[IdasenDeskCoordinator] +UPDATE_DEBOUNCE_TIME = 0.2 + class IdasenDeskCoordinator(DataUpdateCoordinator[int | None]): """Class to manage updates for the Idasen Desk.""" @@ -33,9 +36,22 @@ def __init__( hass, _LOGGER, config_entry=config_entry, name=config_entry.title ) self.address = address + self.desk = Desk(self._async_handle_update) + self._expected_connected = False + self._height: int | None = None - self.desk = Desk(self.async_set_updated_data) + @callback + def async_update_data() -> None: + self.async_set_updated_data(self._height) + + self._debouncer = Debouncer( + hass=self.hass, + logger=_LOGGER, + cooldown=UPDATE_DEBOUNCE_TIME, + immediate=True, + function=async_update_data, + ) async def async_connect(self) -> bool: """Connect to desk.""" @@ -60,3 +76,9 @@ async def async_connect_if_expected(self) -> None: """Ensure that the desk is connected if that is the expected state.""" if self._expected_connected: await self.async_connect() + + @callback + def _async_handle_update(self, height: int | None) -> None: + """Handle an update from the desk.""" + self._height = height + self._debouncer.async_schedule_call() diff --git a/tests/components/idasen_desk/__init__.py b/tests/components/idasen_desk/__init__.py index b0d7cc5ac05b99..42e00157b8f2bf 100644 --- a/tests/components/idasen_desk/__init__.py +++ b/tests/components/idasen_desk/__init__.py @@ -38,6 +38,8 @@ tx_power=-127, ) +UPDATE_DEBOUNCE_TIME = 0.2 + async def init_integration(hass: HomeAssistant) -> MockConfigEntry: """Set up the IKEA Idasen Desk integration in Home Assistant.""" diff --git a/tests/components/idasen_desk/test_cover.py b/tests/components/idasen_desk/test_cover.py index 83312c04e725c4..84861ab6873650 100644 --- a/tests/components/idasen_desk/test_cover.py +++ b/tests/components/idasen_desk/test_cover.py @@ -4,6 +4,7 @@ from unittest.mock import AsyncMock, MagicMock from bleak.exc import BleakError +from freezegun.api import FrozenDateTimeFactory import pytest from homeassistant.components.cover import ( @@ -22,12 +23,13 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError -from . import init_integration +from . import UPDATE_DEBOUNCE_TIME, init_integration + +from tests.common import async_fire_time_changed async def test_cover_available( - hass: HomeAssistant, - mock_desk_api: MagicMock, + hass: HomeAssistant, mock_desk_api: MagicMock, freezer: FrozenDateTimeFactory ) -> None: """Test cover available property.""" entity_id = "cover.test" @@ -42,6 +44,9 @@ async def test_cover_available( mock_desk_api.is_connected = False mock_desk_api.trigger_update_callback(None) + freezer.tick(UPDATE_DEBOUNCE_TIME) + async_fire_time_changed(hass) + state = hass.states.get(entity_id) assert state assert state.state == STATE_UNAVAILABLE @@ -64,6 +69,7 @@ async def test_cover_services( service_data: dict[str, Any], expected_state: str, expected_position: int, + freezer: FrozenDateTimeFactory, ) -> None: """Test cover services.""" entity_id = "cover.test" @@ -78,7 +84,9 @@ async def test_cover_services( {"entity_id": entity_id, **service_data}, blocking=True, ) - await hass.async_block_till_done() + freezer.tick(UPDATE_DEBOUNCE_TIME) + async_fire_time_changed(hass) + state = hass.states.get(entity_id) assert state assert state.state == expected_state @@ -113,4 +121,3 @@ async def test_cover_services_exception( {"entity_id": entity_id, **service_data}, blocking=True, ) - await hass.async_block_till_done() diff --git a/tests/components/idasen_desk/test_sensor.py b/tests/components/idasen_desk/test_sensor.py index 614bce523e6ddb..dc8d6f4adf8d66 100644 --- a/tests/components/idasen_desk/test_sensor.py +++ b/tests/components/idasen_desk/test_sensor.py @@ -2,18 +2,23 @@ from unittest.mock import MagicMock +from freezegun.api import FrozenDateTimeFactory import pytest from homeassistant.const import STATE_UNAVAILABLE from homeassistant.core import HomeAssistant -from . import init_integration +from . import UPDATE_DEBOUNCE_TIME, init_integration + +from tests.common import async_fire_time_changed EXPECTED_INITIAL_HEIGHT = "1" @pytest.mark.usefixtures("entity_registry_enabled_by_default") -async def test_height_sensor(hass: HomeAssistant, mock_desk_api: MagicMock) -> None: +async def test_height_sensor( + hass: HomeAssistant, mock_desk_api: MagicMock, freezer: FrozenDateTimeFactory +) -> None: """Test height sensor.""" await init_integration(hass) @@ -24,6 +29,15 @@ async def test_height_sensor(hass: HomeAssistant, mock_desk_api: MagicMock) -> N mock_desk_api.height = 1.2 mock_desk_api.trigger_update_callback(None) + await hass.async_block_till_done() + + # State should still be the same due to the debouncer + state = hass.states.get(entity_id) + assert state + assert state.state == EXPECTED_INITIAL_HEIGHT + + freezer.tick(UPDATE_DEBOUNCE_TIME) + async_fire_time_changed(hass) state = hass.states.get(entity_id) assert state @@ -34,6 +48,7 @@ async def test_height_sensor(hass: HomeAssistant, mock_desk_api: MagicMock) -> N async def test_sensor_available( hass: HomeAssistant, mock_desk_api: MagicMock, + freezer: FrozenDateTimeFactory, ) -> None: """Test sensor available property.""" await init_integration(hass) @@ -46,6 +61,9 @@ async def test_sensor_available( mock_desk_api.is_connected = False mock_desk_api.trigger_update_callback(None) + freezer.tick(UPDATE_DEBOUNCE_TIME) + async_fire_time_changed(hass) + state = hass.states.get(entity_id) assert state assert state.state == STATE_UNAVAILABLE From 85d8244b8a4416fb7a0c12f29f1de948949137c6 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Fri, 3 Oct 2025 13:16:51 -0400 Subject: [PATCH 03/10] When discovering a Z-Wave adapter, always configure add-on in config flow (#153575) --- .../components/zwave_js/config_flow.py | 32 +++++++- tests/components/zwave_js/test_config_flow.py | 78 ++++++++++++++++++- 2 files changed, 106 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/zwave_js/config_flow.py b/homeassistant/components/zwave_js/config_flow.py index 1909384639d3cc..f1f820fa734f63 100644 --- a/homeassistant/components/zwave_js/config_flow.py +++ b/homeassistant/components/zwave_js/config_flow.py @@ -703,7 +703,15 @@ async def async_step_rf_region( async def async_step_on_supervisor( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: - """Handle logic when on Supervisor host.""" + """Handle logic when on Supervisor host. + + When the add-on is running, we copy over it's settings. + We will ignore settings for USB/Socket if those were discovered. + + If add-on is not running, we will configure the add-on. + + When it's not installed, we install it with new config options. + """ if user_input is None: return self.async_show_form( step_id="on_supervisor", data_schema=ON_SUPERVISOR_SCHEMA @@ -717,8 +725,11 @@ async def async_step_on_supervisor( if addon_info.state == AddonState.RUNNING: addon_config = addon_info.options - self.usb_path = addon_config.get(CONF_ADDON_DEVICE) - self.socket_path = addon_config.get(CONF_ADDON_SOCKET) + # Use the options set by USB/ESPHome discovery + if not self._adapter_discovered: + self.usb_path = addon_config.get(CONF_ADDON_DEVICE) + self.socket_path = addon_config.get(CONF_ADDON_SOCKET) + self.s0_legacy_key = addon_config.get(CONF_ADDON_S0_LEGACY_KEY, "") self.s2_access_control_key = addon_config.get( CONF_ADDON_S2_ACCESS_CONTROL_KEY, "" @@ -931,6 +942,21 @@ async def async_step_finish_addon_setup_user( str(self.version_info.home_id), raise_on_progress=False ) + # When we came from discovery, make sure we update the add-on + if self._adapter_discovered and self.use_addon: + await self._async_set_addon_config( + { + CONF_ADDON_DEVICE: self.usb_path, + CONF_ADDON_SOCKET: self.socket_path, + CONF_ADDON_S0_LEGACY_KEY: self.s0_legacy_key, + CONF_ADDON_S2_ACCESS_CONTROL_KEY: self.s2_access_control_key, + CONF_ADDON_S2_AUTHENTICATED_KEY: self.s2_authenticated_key, + CONF_ADDON_S2_UNAUTHENTICATED_KEY: self.s2_unauthenticated_key, + CONF_ADDON_LR_S2_ACCESS_CONTROL_KEY: self.lr_s2_access_control_key, + CONF_ADDON_LR_S2_AUTHENTICATED_KEY: self.lr_s2_authenticated_key, + } + ) + self._abort_if_unique_id_configured( updates={ CONF_URL: self.ws_address, diff --git a/tests/components/zwave_js/test_config_flow.py b/tests/components/zwave_js/test_config_flow.py index c3dda537db03d7..6310c368fc473c 100644 --- a/tests/components/zwave_js/test_config_flow.py +++ b/tests/components/zwave_js/test_config_flow.py @@ -1168,7 +1168,7 @@ async def mock_restore_nvm(data: bytes, options: dict[str, bool] | None = None): @pytest.mark.usefixtures("supervisor", "addon_not_installed", "addon_info") -async def test_esphome_discovery( +async def test_esphome_discovery_intent_custom( hass: HomeAssistant, install_addon: AsyncMock, set_addon_options: AsyncMock, @@ -1290,6 +1290,82 @@ async def test_esphome_discovery( assert len(mock_setup_entry.mock_calls) == 1 +@pytest.mark.usefixtures("supervisor", "addon_installed", "addon_running", "addon_info") +async def test_esphome_discovery_intent_recommended( + hass: HomeAssistant, + set_addon_options: AsyncMock, + addon_options: dict, +) -> None: + """Test ESPHome discovery success path.""" + addon_options.update( + { + CONF_ADDON_DEVICE: "/dev/ttyUSB0", + CONF_ADDON_SOCKET: None, + "s0_legacy_key": "new123", + "s2_access_control_key": "new456", + "s2_authenticated_key": "new789", + "s2_unauthenticated_key": "new987", + "lr_s2_access_control_key": "new654", + "lr_s2_authenticated_key": "new321", + } + ) + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_ESPHOME}, + data=ESPHOME_DISCOVERY_INFO, + ) + + assert result["type"] is FlowResultType.MENU + assert result["step_id"] == "installation_type" + assert result["menu_options"] == ["intent_recommended", "intent_custom"] + + with ( + patch( + "homeassistant.components.zwave_js.async_setup", return_value=True + ) as mock_setup, + patch( + "homeassistant.components.zwave_js.async_setup_entry", + return_value=True, + ) as mock_setup_entry, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {"next_step_id": "intent_recommended"} + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == TITLE + assert result["result"].unique_id == str(ESPHOME_DISCOVERY_INFO.zwave_home_id) + assert result["data"] == { + "url": "ws://host1:3001", + "usb_path": None, + "socket_path": "esphome://192.168.1.100:6053", + "s0_legacy_key": "new123", + "s2_access_control_key": "new456", + "s2_authenticated_key": "new789", + "s2_unauthenticated_key": "new987", + "lr_s2_access_control_key": "new654", + "lr_s2_authenticated_key": "new321", + "use_addon": True, + "integration_created_addon": False, + } + assert set_addon_options.call_args == call( + "core_zwave_js", + AddonsOptions( + config={ + "socket": "esphome://192.168.1.100:6053", + "s0_legacy_key": "new123", + "s2_access_control_key": "new456", + "s2_authenticated_key": "new789", + "s2_unauthenticated_key": "new987", + "lr_s2_access_control_key": "new654", + "lr_s2_authenticated_key": "new321", + } + ), + ) + assert len(mock_setup.mock_calls) == 1 + assert len(mock_setup_entry.mock_calls) == 1 + + @pytest.mark.usefixtures("supervisor", "addon_installed", "addon_info") async def test_esphome_discovery_already_configured( hass: HomeAssistant, From 7060ab8c44f98889946f30d8cde1bb5156978ca1 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Fri, 3 Oct 2025 20:12:26 +0200 Subject: [PATCH 04/10] Remove Vultr integration (#153560) --- homeassistant/components/vultr/__init__.py | 100 ----------- .../components/vultr/binary_sensor.py | 121 ------------- homeassistant/components/vultr/manifest.json | 10 -- homeassistant/components/vultr/sensor.py | 123 ------------- homeassistant/components/vultr/switch.py | 129 -------------- homeassistant/generated/integrations.json | 6 - requirements_all.txt | 3 - requirements_test_all.txt | 3 - script/hassfest/quality_scale.py | 2 - tests/components/vultr/__init__.py | 1 - tests/components/vultr/conftest.py | 30 ---- tests/components/vultr/const.py | 3 - .../vultr/fixtures/account_info.json | 6 - .../vultr/fixtures/server_list.json | 122 ------------- tests/components/vultr/test_binary_sensor.py | 104 ----------- tests/components/vultr/test_init.py | 30 ---- tests/components/vultr/test_sensor.py | 134 --------------- tests/components/vultr/test_switch.py | 161 ------------------ 18 files changed, 1088 deletions(-) delete mode 100644 homeassistant/components/vultr/__init__.py delete mode 100644 homeassistant/components/vultr/binary_sensor.py delete mode 100644 homeassistant/components/vultr/manifest.json delete mode 100644 homeassistant/components/vultr/sensor.py delete mode 100644 homeassistant/components/vultr/switch.py delete mode 100644 tests/components/vultr/__init__.py delete mode 100644 tests/components/vultr/conftest.py delete mode 100644 tests/components/vultr/const.py delete mode 100644 tests/components/vultr/fixtures/account_info.json delete mode 100644 tests/components/vultr/fixtures/server_list.json delete mode 100644 tests/components/vultr/test_binary_sensor.py delete mode 100644 tests/components/vultr/test_init.py delete mode 100644 tests/components/vultr/test_sensor.py delete mode 100644 tests/components/vultr/test_switch.py diff --git a/homeassistant/components/vultr/__init__.py b/homeassistant/components/vultr/__init__.py deleted file mode 100644 index 66527bf458e7e3..00000000000000 --- a/homeassistant/components/vultr/__init__.py +++ /dev/null @@ -1,100 +0,0 @@ -"""Support for Vultr.""" - -from datetime import timedelta -import logging - -import voluptuous as vol -from vultr import Vultr as VultrAPI - -from homeassistant.components import persistent_notification -from homeassistant.const import CONF_API_KEY, Platform -from homeassistant.core import HomeAssistant -from homeassistant.helpers import config_validation as cv -from homeassistant.helpers.typing import ConfigType -from homeassistant.util import Throttle - -_LOGGER = logging.getLogger(__name__) - -ATTR_AUTO_BACKUPS = "auto_backups" -ATTR_ALLOWED_BANDWIDTH = "allowed_bandwidth_gb" -ATTR_COST_PER_MONTH = "cost_per_month" -ATTR_CURRENT_BANDWIDTH_USED = "current_bandwidth_gb" -ATTR_CREATED_AT = "created_at" -ATTR_DISK = "disk" -ATTR_SUBSCRIPTION_ID = "subid" -ATTR_SUBSCRIPTION_NAME = "label" -ATTR_IPV4_ADDRESS = "ipv4_address" -ATTR_IPV6_ADDRESS = "ipv6_address" -ATTR_MEMORY = "memory" -ATTR_OS = "os" -ATTR_PENDING_CHARGES = "pending_charges" -ATTR_REGION = "region" -ATTR_VCPUS = "vcpus" - -CONF_SUBSCRIPTION = "subscription" - -DATA_VULTR = "data_vultr" -DOMAIN = "vultr" - -NOTIFICATION_ID = "vultr_notification" -NOTIFICATION_TITLE = "Vultr Setup" - -VULTR_PLATFORMS = [Platform.BINARY_SENSOR, Platform.SENSOR, Platform.SWITCH] - -MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=60) - -CONFIG_SCHEMA = vol.Schema( - {DOMAIN: vol.Schema({vol.Required(CONF_API_KEY): cv.string})}, extra=vol.ALLOW_EXTRA -) - - -def setup(hass: HomeAssistant, config: ConfigType) -> bool: - """Set up the Vultr component.""" - api_key = config[DOMAIN].get(CONF_API_KEY) - - vultr = Vultr(api_key) - - try: - vultr.update() - except RuntimeError as ex: - _LOGGER.error("Failed to make update API request because: %s", ex) - persistent_notification.create( - hass, - f"Error: {ex}", - title=NOTIFICATION_TITLE, - notification_id=NOTIFICATION_ID, - ) - return False - - hass.data[DATA_VULTR] = vultr - return True - - -class Vultr: - """Handle all communication with the Vultr API.""" - - def __init__(self, api_key): - """Initialize the Vultr connection.""" - - self._api_key = api_key - self.data = None - self.api = VultrAPI(self._api_key) - - @Throttle(MIN_TIME_BETWEEN_UPDATES) - def update(self): - """Use the data from Vultr API.""" - self.data = self.api.server_list() - - def _force_update(self): - """Use the data from Vultr API.""" - self.data = self.api.server_list() - - def halt(self, subscription): - """Halt a subscription (hard power off).""" - self.api.server_halt(subscription) - self._force_update() - - def start(self, subscription): - """Start a subscription.""" - self.api.server_start(subscription) - self._force_update() diff --git a/homeassistant/components/vultr/binary_sensor.py b/homeassistant/components/vultr/binary_sensor.py deleted file mode 100644 index 3972de8a625476..00000000000000 --- a/homeassistant/components/vultr/binary_sensor.py +++ /dev/null @@ -1,121 +0,0 @@ -"""Support for monitoring the state of Vultr subscriptions (VPS).""" - -from __future__ import annotations - -import logging - -import voluptuous as vol - -from homeassistant.components.binary_sensor import ( - PLATFORM_SCHEMA as BINARY_SENSOR_PLATFORM_SCHEMA, - BinarySensorDeviceClass, - BinarySensorEntity, -) -from homeassistant.const import CONF_NAME -from homeassistant.core import HomeAssistant -from homeassistant.helpers import config_validation as cv -from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType - -from . import ( - ATTR_ALLOWED_BANDWIDTH, - ATTR_AUTO_BACKUPS, - ATTR_COST_PER_MONTH, - ATTR_CREATED_AT, - ATTR_DISK, - ATTR_IPV4_ADDRESS, - ATTR_IPV6_ADDRESS, - ATTR_MEMORY, - ATTR_OS, - ATTR_REGION, - ATTR_SUBSCRIPTION_ID, - ATTR_SUBSCRIPTION_NAME, - ATTR_VCPUS, - CONF_SUBSCRIPTION, - DATA_VULTR, -) - -_LOGGER = logging.getLogger(__name__) - -DEFAULT_NAME = "Vultr {}" -PLATFORM_SCHEMA = BINARY_SENSOR_PLATFORM_SCHEMA.extend( - { - vol.Required(CONF_SUBSCRIPTION): cv.string, - vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, - } -) - - -def setup_platform( - hass: HomeAssistant, - config: ConfigType, - add_entities: AddEntitiesCallback, - discovery_info: DiscoveryInfoType | None = None, -) -> None: - """Set up the Vultr subscription (server) binary sensor.""" - vultr = hass.data[DATA_VULTR] - - subscription = config.get(CONF_SUBSCRIPTION) - name = config.get(CONF_NAME) - - if subscription not in vultr.data: - _LOGGER.error("Subscription %s not found", subscription) - return - - add_entities([VultrBinarySensor(vultr, subscription, name)], True) - - -class VultrBinarySensor(BinarySensorEntity): - """Representation of a Vultr subscription sensor.""" - - _attr_device_class = BinarySensorDeviceClass.POWER - - def __init__(self, vultr, subscription, name): - """Initialize a new Vultr binary sensor.""" - self._vultr = vultr - self._name = name - - self.subscription = subscription - self.data = None - - @property - def name(self): - """Return the name of the sensor.""" - try: - return self._name.format(self.data["label"]) - except (KeyError, TypeError): - return self._name - - @property - def icon(self): - """Return the icon of this server.""" - return "mdi:server" if self.is_on else "mdi:server-off" - - @property - def is_on(self): - """Return true if the binary sensor is on.""" - return self.data["power_status"] == "running" - - @property - def extra_state_attributes(self): - """Return the state attributes of the Vultr subscription.""" - return { - ATTR_ALLOWED_BANDWIDTH: self.data.get("allowed_bandwidth_gb"), - ATTR_AUTO_BACKUPS: self.data.get("auto_backups"), - ATTR_COST_PER_MONTH: self.data.get("cost_per_month"), - ATTR_CREATED_AT: self.data.get("date_created"), - ATTR_DISK: self.data.get("disk"), - ATTR_IPV4_ADDRESS: self.data.get("main_ip"), - ATTR_IPV6_ADDRESS: self.data.get("v6_main_ip"), - ATTR_MEMORY: self.data.get("ram"), - ATTR_OS: self.data.get("os"), - ATTR_REGION: self.data.get("location"), - ATTR_SUBSCRIPTION_ID: self.data.get("SUBID"), - ATTR_SUBSCRIPTION_NAME: self.data.get("label"), - ATTR_VCPUS: self.data.get("vcpu_count"), - } - - def update(self) -> None: - """Update state of sensor.""" - self._vultr.update() - self.data = self._vultr.data[self.subscription] diff --git a/homeassistant/components/vultr/manifest.json b/homeassistant/components/vultr/manifest.json deleted file mode 100644 index 713485e79317fd..00000000000000 --- a/homeassistant/components/vultr/manifest.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "domain": "vultr", - "name": "Vultr", - "codeowners": [], - "documentation": "https://www.home-assistant.io/integrations/vultr", - "iot_class": "cloud_polling", - "loggers": ["vultr"], - "quality_scale": "legacy", - "requirements": ["vultr==0.1.2"] -} diff --git a/homeassistant/components/vultr/sensor.py b/homeassistant/components/vultr/sensor.py deleted file mode 100644 index c392c382cbd421..00000000000000 --- a/homeassistant/components/vultr/sensor.py +++ /dev/null @@ -1,123 +0,0 @@ -"""Support for monitoring the state of Vultr Subscriptions.""" - -from __future__ import annotations - -import logging - -import voluptuous as vol - -from homeassistant.components.sensor import ( - PLATFORM_SCHEMA as SENSOR_PLATFORM_SCHEMA, - SensorDeviceClass, - SensorEntity, - SensorEntityDescription, -) -from homeassistant.const import CONF_MONITORED_CONDITIONS, CONF_NAME, UnitOfInformation -from homeassistant.core import HomeAssistant -from homeassistant.helpers import config_validation as cv -from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType - -from . import ( - ATTR_CURRENT_BANDWIDTH_USED, - ATTR_PENDING_CHARGES, - CONF_SUBSCRIPTION, - DATA_VULTR, -) - -_LOGGER = logging.getLogger(__name__) - -DEFAULT_NAME = "Vultr {} {}" -SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( - SensorEntityDescription( - key=ATTR_CURRENT_BANDWIDTH_USED, - name="Current Bandwidth Used", - native_unit_of_measurement=UnitOfInformation.GIGABYTES, - device_class=SensorDeviceClass.DATA_SIZE, - icon="mdi:chart-histogram", - ), - SensorEntityDescription( - key=ATTR_PENDING_CHARGES, - name="Pending Charges", - native_unit_of_measurement="US$", - icon="mdi:currency-usd", - ), -) -SENSOR_KEYS: list[str] = [desc.key for desc in SENSOR_TYPES] - -PLATFORM_SCHEMA = SENSOR_PLATFORM_SCHEMA.extend( - { - vol.Required(CONF_SUBSCRIPTION): cv.string, - vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, - vol.Optional(CONF_MONITORED_CONDITIONS, default=SENSOR_KEYS): vol.All( - cv.ensure_list, [vol.In(SENSOR_KEYS)] - ), - } -) - - -def setup_platform( - hass: HomeAssistant, - config: ConfigType, - add_entities: AddEntitiesCallback, - discovery_info: DiscoveryInfoType | None = None, -) -> None: - """Set up the Vultr subscription (server) sensor.""" - vultr = hass.data[DATA_VULTR] - - subscription = config[CONF_SUBSCRIPTION] - name = config[CONF_NAME] - monitored_conditions = config[CONF_MONITORED_CONDITIONS] - - if subscription not in vultr.data: - _LOGGER.error("Subscription %s not found", subscription) - return - - entities = [ - VultrSensor(vultr, subscription, name, description) - for description in SENSOR_TYPES - if description.key in monitored_conditions - ] - - add_entities(entities, True) - - -class VultrSensor(SensorEntity): - """Representation of a Vultr subscription sensor.""" - - def __init__( - self, vultr, subscription, name, description: SensorEntityDescription - ) -> None: - """Initialize a new Vultr sensor.""" - self.entity_description = description - self._vultr = vultr - self._name = name - - self.subscription = subscription - self.data = None - - @property - def name(self): - """Return the name of the sensor.""" - try: - return self._name.format(self.entity_description.name) - except IndexError: - try: - return self._name.format( - self.data["label"], self.entity_description.name - ) - except (KeyError, TypeError): - return self._name - - @property - def native_value(self): - """Return the value of this given sensor type.""" - try: - return round(float(self.data.get(self.entity_description.key)), 2) - except (TypeError, ValueError): - return self.data.get(self.entity_description.key) - - def update(self) -> None: - """Update state of sensor.""" - self._vultr.update() - self.data = self._vultr.data[self.subscription] diff --git a/homeassistant/components/vultr/switch.py b/homeassistant/components/vultr/switch.py deleted file mode 100644 index 0b1f2247684015..00000000000000 --- a/homeassistant/components/vultr/switch.py +++ /dev/null @@ -1,129 +0,0 @@ -"""Support for interacting with Vultr subscriptions.""" - -from __future__ import annotations - -import logging -from typing import Any - -import voluptuous as vol - -from homeassistant.components.switch import ( - PLATFORM_SCHEMA as SWITCH_PLATFORM_SCHEMA, - SwitchEntity, -) -from homeassistant.const import CONF_NAME -from homeassistant.core import HomeAssistant -from homeassistant.helpers import config_validation as cv -from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType - -from . import ( - ATTR_ALLOWED_BANDWIDTH, - ATTR_AUTO_BACKUPS, - ATTR_COST_PER_MONTH, - ATTR_CREATED_AT, - ATTR_DISK, - ATTR_IPV4_ADDRESS, - ATTR_IPV6_ADDRESS, - ATTR_MEMORY, - ATTR_OS, - ATTR_REGION, - ATTR_SUBSCRIPTION_ID, - ATTR_SUBSCRIPTION_NAME, - ATTR_VCPUS, - CONF_SUBSCRIPTION, - DATA_VULTR, -) - -_LOGGER = logging.getLogger(__name__) - -DEFAULT_NAME = "Vultr {}" -PLATFORM_SCHEMA = SWITCH_PLATFORM_SCHEMA.extend( - { - vol.Required(CONF_SUBSCRIPTION): cv.string, - vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, - } -) - - -def setup_platform( - hass: HomeAssistant, - config: ConfigType, - add_entities: AddEntitiesCallback, - discovery_info: DiscoveryInfoType | None = None, -) -> None: - """Set up the Vultr subscription switch.""" - vultr = hass.data[DATA_VULTR] - - subscription = config.get(CONF_SUBSCRIPTION) - name = config.get(CONF_NAME) - - if subscription not in vultr.data: - _LOGGER.error("Subscription %s not found", subscription) - return - - add_entities([VultrSwitch(vultr, subscription, name)], True) - - -class VultrSwitch(SwitchEntity): - """Representation of a Vultr subscription switch.""" - - def __init__(self, vultr, subscription, name): - """Initialize a new Vultr switch.""" - self._vultr = vultr - self._name = name - - self.subscription = subscription - self.data = None - - @property - def name(self): - """Return the name of the switch.""" - try: - return self._name.format(self.data["label"]) - except (TypeError, KeyError): - return self._name - - @property - def is_on(self): - """Return true if switch is on.""" - return self.data["power_status"] == "running" - - @property - def icon(self): - """Return the icon of this server.""" - return "mdi:server" if self.is_on else "mdi:server-off" - - @property - def extra_state_attributes(self): - """Return the state attributes of the Vultr subscription.""" - return { - ATTR_ALLOWED_BANDWIDTH: self.data.get("allowed_bandwidth_gb"), - ATTR_AUTO_BACKUPS: self.data.get("auto_backups"), - ATTR_COST_PER_MONTH: self.data.get("cost_per_month"), - ATTR_CREATED_AT: self.data.get("date_created"), - ATTR_DISK: self.data.get("disk"), - ATTR_IPV4_ADDRESS: self.data.get("main_ip"), - ATTR_IPV6_ADDRESS: self.data.get("v6_main_ip"), - ATTR_MEMORY: self.data.get("ram"), - ATTR_OS: self.data.get("os"), - ATTR_REGION: self.data.get("location"), - ATTR_SUBSCRIPTION_ID: self.data.get("SUBID"), - ATTR_SUBSCRIPTION_NAME: self.data.get("label"), - ATTR_VCPUS: self.data.get("vcpu_count"), - } - - def turn_on(self, **kwargs: Any) -> None: - """Boot-up the subscription.""" - if self.data["power_status"] != "running": - self._vultr.start(self.subscription) - - def turn_off(self, **kwargs: Any) -> None: - """Halt the subscription.""" - if self.data["power_status"] == "running": - self._vultr.halt(self.subscription) - - def update(self) -> None: - """Get the latest data from the device and update the data.""" - self._vultr.update() - self.data = self._vultr.data[self.subscription] diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 08f08b24d59771..ef6dfdfc823fca 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -7411,12 +7411,6 @@ "config_flow": true, "iot_class": "cloud_polling" }, - "vultr": { - "name": "Vultr", - "integration_type": "hub", - "config_flow": false, - "iot_class": "cloud_polling" - }, "w800rf32": { "name": "WGL Designs W800RF32", "integration_type": "hub", diff --git a/requirements_all.txt b/requirements_all.txt index 66db3f255a4626..f9538821e84a3f 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -3118,9 +3118,6 @@ vsure==2.6.7 # homeassistant.components.vasttrafik vtjp==0.2.1 -# homeassistant.components.vultr -vultr==0.1.2 - # homeassistant.components.samsungtv # homeassistant.components.wake_on_lan wakeonlan==3.1.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index a21f6af01e7b4a..e7c0eace31d637 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2583,9 +2583,6 @@ volvocarsapi==0.4.2 # homeassistant.components.verisure vsure==2.6.7 -# homeassistant.components.vultr -vultr==0.1.2 - # homeassistant.components.samsungtv # homeassistant.components.wake_on_lan wakeonlan==3.1.0 diff --git a/script/hassfest/quality_scale.py b/script/hassfest/quality_scale.py index 01cca31a90dfea..97c8a63a1f6837 100644 --- a/script/hassfest/quality_scale.py +++ b/script/hassfest/quality_scale.py @@ -1062,7 +1062,6 @@ class Rule: "volkszaehler", "volumio", "volvooncall", - "vultr", "w800rf32", "wake_on_lan", "wallbox", @@ -2112,7 +2111,6 @@ class Rule: "volkszaehler", "volumio", "volvooncall", - "vultr", "w800rf32", "wake_on_lan", "wallbox", diff --git a/tests/components/vultr/__init__.py b/tests/components/vultr/__init__.py deleted file mode 100644 index fb25b7e145e76a..00000000000000 --- a/tests/components/vultr/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Tests for the vultr component.""" diff --git a/tests/components/vultr/conftest.py b/tests/components/vultr/conftest.py deleted file mode 100644 index ae0ce9d68864a1..00000000000000 --- a/tests/components/vultr/conftest.py +++ /dev/null @@ -1,30 +0,0 @@ -"""Test configuration for the Vultr tests.""" - -import json -from unittest.mock import patch - -import pytest -from requests_mock import Mocker - -from homeassistant.components import vultr -from homeassistant.core import HomeAssistant - -from .const import VALID_CONFIG - -from tests.common import load_fixture - - -@pytest.fixture(name="valid_config") -def valid_config(hass: HomeAssistant, requests_mock: Mocker) -> None: - """Load a valid config.""" - requests_mock.get( - "https://api.vultr.com/v1/account/info?api_key=ABCDEFG1234567", - text=load_fixture("account_info.json", "vultr"), - ) - - with patch( - "vultr.Vultr.server_list", - return_value=json.loads(load_fixture("server_list.json", "vultr")), - ): - # Setup hub - vultr.setup(hass, VALID_CONFIG) diff --git a/tests/components/vultr/const.py b/tests/components/vultr/const.py deleted file mode 100644 index 06bbf2a74835e7..00000000000000 --- a/tests/components/vultr/const.py +++ /dev/null @@ -1,3 +0,0 @@ -"""Constants for the Vultr tests.""" - -VALID_CONFIG = {"vultr": {"api_key": "ABCDEFG1234567"}} diff --git a/tests/components/vultr/fixtures/account_info.json b/tests/components/vultr/fixtures/account_info.json deleted file mode 100644 index 89845dff4cecbc..00000000000000 --- a/tests/components/vultr/fixtures/account_info.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "balance": "-123.00", - "pending_charges": "3.38", - "last_payment_date": "2017-08-11 15:04:04", - "last_payment_amount": "-10.00" -} diff --git a/tests/components/vultr/fixtures/server_list.json b/tests/components/vultr/fixtures/server_list.json deleted file mode 100644 index 259f2931e7f74d..00000000000000 --- a/tests/components/vultr/fixtures/server_list.json +++ /dev/null @@ -1,122 +0,0 @@ -{ - "576965": { - "SUBID": "576965", - "os": "CentOS 6 x64", - "ram": "4096 MB", - "disk": "Virtual 60 GB", - "main_ip": "123.123.123.123", - "vcpu_count": "2", - "location": "New Jersey", - "DCID": "1", - "default_password": "nreqnusibni", - "date_created": "2013-12-19 14:45:41", - "pending_charges": "46.67", - "status": "active", - "cost_per_month": "10.05", - "current_bandwidth_gb": 131.512, - "allowed_bandwidth_gb": "1000", - "netmask_v4": "255.255.255.248", - "gateway_v4": "123.123.123.1", - "power_status": "running", - "server_state": "ok", - "VPSPLANID": "28", - "v6_network": "2001:DB8:1000::", - "v6_main_ip": "2001:DB8:1000::100", - "v6_network_size": "64", - "v6_networks": [ - { - "v6_network": "2001:DB8:1000::", - "v6_main_ip": "2001:DB8:1000::100", - "v6_network_size": "64" - } - ], - "label": "my new server", - "internal_ip": "10.99.0.10", - "kvm_url": "https://my.vultr.com/subs/novnc/api.php?data=eawxFVZw2mXnhGUV", - "auto_backups": "yes", - "tag": "mytag", - "OSID": "127", - "APPID": "0", - "FIREWALLGROUPID": "0" - }, - "123456": { - "SUBID": "123456", - "os": "CentOS 6 x64", - "ram": "4096 MB", - "disk": "Virtual 60 GB", - "main_ip": "192.168.100.50", - "vcpu_count": "2", - "location": "New Jersey", - "DCID": "1", - "default_password": "nreqnusibni", - "date_created": "2014-10-13 14:45:41", - "pending_charges": "3.72", - "status": "active", - "cost_per_month": "73.25", - "current_bandwidth_gb": 957.457, - "allowed_bandwidth_gb": "1000", - "netmask_v4": "255.255.255.248", - "gateway_v4": "123.123.123.1", - "power_status": "halted", - "server_state": "ok", - "VPSPLANID": "28", - "v6_network": "2001:DB8:1000::", - "v6_main_ip": "2001:DB8:1000::100", - "v6_network_size": "64", - "v6_networks": [ - { - "v6_network": "2001:DB8:1000::", - "v6_main_ip": "2001:DB8:1000::100", - "v6_network_size": "64" - } - ], - "label": "my failed server", - "internal_ip": "10.99.0.10", - "kvm_url": "https://my.vultr.com/subs/novnc/api.php?data=eawxFVZw2mXnhGUV", - "auto_backups": "no", - "tag": "mytag", - "OSID": "127", - "APPID": "0", - "FIREWALLGROUPID": "0" - }, - "555555": { - "SUBID": "555555", - "os": "CentOS 7 x64", - "ram": "1024 MB", - "disk": "Virtual 30 GB", - "main_ip": "192.168.250.50", - "vcpu_count": "1", - "location": "London", - "DCID": "7", - "default_password": "password", - "date_created": "2014-10-15 14:45:41", - "pending_charges": "5.45", - "status": "active", - "cost_per_month": "73.25", - "current_bandwidth_gb": 57.457, - "allowed_bandwidth_gb": "100", - "netmask_v4": "255.255.255.248", - "gateway_v4": "123.123.123.1", - "power_status": "halted", - "server_state": "ok", - "VPSPLANID": "28", - "v6_network": "2001:DB8:1000::", - "v6_main_ip": "2001:DB8:1000::100", - "v6_network_size": "64", - "v6_networks": [ - { - "v6_network": "2001:DB8:1000::", - "v6_main_ip": "2001:DB8:1000::100", - "v6_network_size": "64" - } - ], - "label": "Another Server", - "internal_ip": "10.99.0.10", - "kvm_url": "https://my.vultr.com/subs/novnc/api.php?data=eawxFVZw2mXnhGUV", - "auto_backups": "no", - "tag": "mytag", - "OSID": "127", - "APPID": "0", - "FIREWALLGROUPID": "0" - } -} diff --git a/tests/components/vultr/test_binary_sensor.py b/tests/components/vultr/test_binary_sensor.py deleted file mode 100644 index f6b46b54d25baf..00000000000000 --- a/tests/components/vultr/test_binary_sensor.py +++ /dev/null @@ -1,104 +0,0 @@ -"""Test the Vultr binary sensor platform.""" - -import pytest -import voluptuous as vol - -from homeassistant.components import vultr as base_vultr -from homeassistant.components.vultr import ( - ATTR_ALLOWED_BANDWIDTH, - ATTR_AUTO_BACKUPS, - ATTR_COST_PER_MONTH, - ATTR_CREATED_AT, - ATTR_IPV4_ADDRESS, - ATTR_SUBSCRIPTION_ID, - CONF_SUBSCRIPTION, - binary_sensor as vultr, -) -from homeassistant.const import CONF_NAME, CONF_PLATFORM -from homeassistant.core import HomeAssistant - -CONFIGS = [ - {CONF_SUBSCRIPTION: "576965", CONF_NAME: "A Server"}, - {CONF_SUBSCRIPTION: "123456", CONF_NAME: "Failed Server"}, - {CONF_SUBSCRIPTION: "555555", CONF_NAME: vultr.DEFAULT_NAME}, -] - - -@pytest.mark.usefixtures("valid_config") -def test_binary_sensor(hass: HomeAssistant) -> None: - """Test successful instance.""" - hass_devices = [] - - def add_entities(devices, action): - """Mock add devices.""" - for device in devices: - device.hass = hass - hass_devices.append(device) - - # Setup each of our test configs - for config in CONFIGS: - vultr.setup_platform(hass, config, add_entities, None) - - assert len(hass_devices) == 3 - - for device in hass_devices: - # Test pre data retrieval - if device.subscription == "555555": - assert device.name == "Vultr {}" - - device.update() - device_attrs = device.extra_state_attributes - - if device.subscription == "555555": - assert device.name == "Vultr Another Server" - - if device.name == "A Server": - assert device.is_on is True - assert device.device_class == "power" - assert device.state == "on" - assert device.icon == "mdi:server" - assert device_attrs[ATTR_ALLOWED_BANDWIDTH] == "1000" - assert device_attrs[ATTR_AUTO_BACKUPS] == "yes" - assert device_attrs[ATTR_IPV4_ADDRESS] == "123.123.123.123" - assert device_attrs[ATTR_COST_PER_MONTH] == "10.05" - assert device_attrs[ATTR_CREATED_AT] == "2013-12-19 14:45:41" - assert device_attrs[ATTR_SUBSCRIPTION_ID] == "576965" - elif device.name == "Failed Server": - assert device.is_on is False - assert device.state == "off" - assert device.icon == "mdi:server-off" - assert device_attrs[ATTR_ALLOWED_BANDWIDTH] == "1000" - assert device_attrs[ATTR_AUTO_BACKUPS] == "no" - assert device_attrs[ATTR_IPV4_ADDRESS] == "192.168.100.50" - assert device_attrs[ATTR_COST_PER_MONTH] == "73.25" - assert device_attrs[ATTR_CREATED_AT] == "2014-10-13 14:45:41" - assert device_attrs[ATTR_SUBSCRIPTION_ID] == "123456" - - -def test_invalid_sensor_config() -> None: - """Test config type failures.""" - with pytest.raises(vol.Invalid): # No subs - vultr.PLATFORM_SCHEMA({CONF_PLATFORM: base_vultr.DOMAIN}) - - -@pytest.mark.usefixtures("valid_config") -def test_invalid_sensors(hass: HomeAssistant) -> None: - """Test the VultrBinarySensor fails.""" - hass_devices = [] - - def add_entities(devices, action): - """Mock add devices.""" - for device in devices: - device.hass = hass - hass_devices.append(device) - - bad_conf = {} # No subscription - - vultr.setup_platform(hass, bad_conf, add_entities, None) - - bad_conf = { - CONF_NAME: "Missing Server", - CONF_SUBSCRIPTION: "555555", - } # Sub not associated with API key (not in server_list) - - vultr.setup_platform(hass, bad_conf, add_entities, None) diff --git a/tests/components/vultr/test_init.py b/tests/components/vultr/test_init.py deleted file mode 100644 index 8c5ec51f584a93..00000000000000 --- a/tests/components/vultr/test_init.py +++ /dev/null @@ -1,30 +0,0 @@ -"""The tests for the Vultr component.""" - -from copy import deepcopy -import json -from unittest.mock import patch - -from homeassistant import setup -from homeassistant.components import vultr -from homeassistant.core import HomeAssistant - -from .const import VALID_CONFIG - -from tests.common import load_fixture - - -def test_setup(hass: HomeAssistant) -> None: - """Test successful setup.""" - with patch( - "vultr.Vultr.server_list", - return_value=json.loads(load_fixture("server_list.json", "vultr")), - ): - response = vultr.setup(hass, VALID_CONFIG) - assert response - - -async def test_setup_no_api_key(hass: HomeAssistant) -> None: - """Test failed setup with missing API Key.""" - conf = deepcopy(VALID_CONFIG) - del conf["vultr"]["api_key"] - assert not await setup.async_setup_component(hass, vultr.DOMAIN, conf) diff --git a/tests/components/vultr/test_sensor.py b/tests/components/vultr/test_sensor.py deleted file mode 100644 index 65be23fc1683ef..00000000000000 --- a/tests/components/vultr/test_sensor.py +++ /dev/null @@ -1,134 +0,0 @@ -"""The tests for the Vultr sensor platform.""" - -import pytest -import voluptuous as vol - -from homeassistant.components import vultr as base_vultr -from homeassistant.components.vultr import CONF_SUBSCRIPTION, sensor as vultr -from homeassistant.const import ( - CONF_MONITORED_CONDITIONS, - CONF_NAME, - CONF_PLATFORM, - UnitOfInformation, -) -from homeassistant.core import HomeAssistant - -CONFIGS = [ - { - CONF_NAME: vultr.DEFAULT_NAME, - CONF_SUBSCRIPTION: "576965", - CONF_MONITORED_CONDITIONS: vultr.SENSOR_KEYS, - }, - { - CONF_NAME: "Server {}", - CONF_SUBSCRIPTION: "123456", - CONF_MONITORED_CONDITIONS: vultr.SENSOR_KEYS, - }, - { - CONF_NAME: "VPS Charges", - CONF_SUBSCRIPTION: "555555", - CONF_MONITORED_CONDITIONS: ["pending_charges"], - }, -] - - -@pytest.mark.usefixtures("valid_config") -def test_sensor(hass: HomeAssistant) -> None: - """Test the Vultr sensor class and methods.""" - hass_devices = [] - - def add_entities(devices, action): - """Mock add devices.""" - for device in devices: - device.hass = hass - hass_devices.append(device) - - for config in CONFIGS: - vultr.setup_platform(hass, config, add_entities, None) - - assert len(hass_devices) == 5 - - tested = 0 - - for device in hass_devices: - # Test pre update - if device.subscription == "576965": - assert device.name == vultr.DEFAULT_NAME - - device.update() - - if ( - device.unit_of_measurement == UnitOfInformation.GIGABYTES - ): # Test Bandwidth Used - if device.subscription == "576965": - assert device.name == "Vultr my new server Current Bandwidth Used" - assert device.icon == "mdi:chart-histogram" - assert device.state == 131.51 - assert device.icon == "mdi:chart-histogram" - tested += 1 - - elif device.subscription == "123456": - assert device.name == "Server Current Bandwidth Used" - assert device.state == 957.46 - tested += 1 - - elif device.unit_of_measurement == "US$": # Test Pending Charges - if device.subscription == "576965": # Default 'Vultr {} {}' - assert device.name == "Vultr my new server Pending Charges" - assert device.icon == "mdi:currency-usd" - assert device.state == 46.67 - assert device.icon == "mdi:currency-usd" - tested += 1 - - elif device.subscription == "123456": # Custom name with 1 {} - assert device.name == "Server Pending Charges" - assert device.state == 3.72 - tested += 1 - - elif device.subscription == "555555": # No {} in name - assert device.name == "VPS Charges" - assert device.state == 5.45 - tested += 1 - - assert tested == 5 - - -def test_invalid_sensor_config() -> None: - """Test config type failures.""" - with pytest.raises(vol.Invalid): # No subscription - vultr.PLATFORM_SCHEMA( - { - CONF_PLATFORM: base_vultr.DOMAIN, - CONF_MONITORED_CONDITIONS: vultr.SENSOR_KEYS, - } - ) - with pytest.raises(vol.Invalid): # Bad monitored_conditions - vultr.PLATFORM_SCHEMA( - { - CONF_PLATFORM: base_vultr.DOMAIN, - CONF_SUBSCRIPTION: "123456", - CONF_MONITORED_CONDITIONS: ["non-existent-condition"], - } - ) - - -@pytest.mark.usefixtures("valid_config") -def test_invalid_sensors(hass: HomeAssistant) -> None: - """Test the VultrSensor fails.""" - hass_devices = [] - - def add_entities(devices, action): - """Mock add devices.""" - for device in devices: - device.hass = hass - hass_devices.append(device) - - bad_conf = { - CONF_NAME: "Vultr {} {}", - CONF_SUBSCRIPTION: "", - CONF_MONITORED_CONDITIONS: vultr.SENSOR_KEYS, - } # No subs at all - - vultr.setup_platform(hass, bad_conf, add_entities, None) - - assert len(hass_devices) == 0 diff --git a/tests/components/vultr/test_switch.py b/tests/components/vultr/test_switch.py deleted file mode 100644 index 14c88d1e878df8..00000000000000 --- a/tests/components/vultr/test_switch.py +++ /dev/null @@ -1,161 +0,0 @@ -"""Test the Vultr switch platform.""" - -from __future__ import annotations - -import json -from unittest.mock import patch - -import pytest -import voluptuous as vol - -from homeassistant.components import vultr as base_vultr -from homeassistant.components.vultr import ( - ATTR_ALLOWED_BANDWIDTH, - ATTR_AUTO_BACKUPS, - ATTR_COST_PER_MONTH, - ATTR_CREATED_AT, - ATTR_IPV4_ADDRESS, - ATTR_SUBSCRIPTION_ID, - CONF_SUBSCRIPTION, - switch as vultr, -) -from homeassistant.const import CONF_NAME, CONF_PLATFORM -from homeassistant.core import HomeAssistant - -from tests.common import load_fixture - -CONFIGS = [ - {CONF_SUBSCRIPTION: "576965", CONF_NAME: "A Server"}, - {CONF_SUBSCRIPTION: "123456", CONF_NAME: "Failed Server"}, - {CONF_SUBSCRIPTION: "555555", CONF_NAME: vultr.DEFAULT_NAME}, -] - - -@pytest.fixture(name="hass_devices") -def load_hass_devices(hass: HomeAssistant): - """Load a valid config.""" - hass_devices = [] - - def add_entities(devices, action): - """Mock add devices.""" - for device in devices: - device.hass = hass - hass_devices.append(device) - - # Setup each of our test configs - for config in CONFIGS: - vultr.setup_platform(hass, config, add_entities, None) - - return hass_devices - - -@pytest.mark.usefixtures("valid_config") -def test_switch(hass: HomeAssistant, hass_devices: list[vultr.VultrSwitch]) -> None: - """Test successful instance.""" - - assert len(hass_devices) == 3 - - tested = 0 - - for device in hass_devices: - if device.subscription == "555555": - assert device.name == "Vultr {}" - tested += 1 - - device.update() - device_attrs = device.extra_state_attributes - - if device.subscription == "555555": - assert device.name == "Vultr Another Server" - tested += 1 - - if device.name == "A Server": - assert device.is_on is True - assert device.state == "on" - assert device.icon == "mdi:server" - assert device_attrs[ATTR_ALLOWED_BANDWIDTH] == "1000" - assert device_attrs[ATTR_AUTO_BACKUPS] == "yes" - assert device_attrs[ATTR_IPV4_ADDRESS] == "123.123.123.123" - assert device_attrs[ATTR_COST_PER_MONTH] == "10.05" - assert device_attrs[ATTR_CREATED_AT] == "2013-12-19 14:45:41" - assert device_attrs[ATTR_SUBSCRIPTION_ID] == "576965" - tested += 1 - - elif device.name == "Failed Server": - assert device.is_on is False - assert device.state == "off" - assert device.icon == "mdi:server-off" - assert device_attrs[ATTR_ALLOWED_BANDWIDTH] == "1000" - assert device_attrs[ATTR_AUTO_BACKUPS] == "no" - assert device_attrs[ATTR_IPV4_ADDRESS] == "192.168.100.50" - assert device_attrs[ATTR_COST_PER_MONTH] == "73.25" - assert device_attrs[ATTR_CREATED_AT] == "2014-10-13 14:45:41" - assert device_attrs[ATTR_SUBSCRIPTION_ID] == "123456" - tested += 1 - - assert tested == 4 - - -@pytest.mark.usefixtures("valid_config") -def test_turn_on(hass: HomeAssistant, hass_devices: list[vultr.VultrSwitch]) -> None: - """Test turning a subscription on.""" - with ( - patch( - "vultr.Vultr.server_list", - return_value=json.loads(load_fixture("server_list.json", "vultr")), - ), - patch("vultr.Vultr.server_start") as mock_start, - ): - for device in hass_devices: - if device.name == "Failed Server": - device.update() - device.turn_on() - - # Turn on - assert mock_start.call_count == 1 - - -@pytest.mark.usefixtures("valid_config") -def test_turn_off(hass: HomeAssistant, hass_devices: list[vultr.VultrSwitch]) -> None: - """Test turning a subscription off.""" - with ( - patch( - "vultr.Vultr.server_list", - return_value=json.loads(load_fixture("server_list.json", "vultr")), - ), - patch("vultr.Vultr.server_halt") as mock_halt, - ): - for device in hass_devices: - if device.name == "A Server": - device.update() - device.turn_off() - - # Turn off - assert mock_halt.call_count == 1 - - -def test_invalid_switch_config() -> None: - """Test config type failures.""" - with pytest.raises(vol.Invalid): # No subscription - vultr.PLATFORM_SCHEMA({CONF_PLATFORM: base_vultr.DOMAIN}) - - -@pytest.mark.usefixtures("valid_config") -def test_invalid_switches(hass: HomeAssistant) -> None: - """Test the VultrSwitch fails.""" - hass_devices = [] - - def add_entities(devices, action): - """Mock add devices.""" - hass_devices.extend(devices) - - bad_conf = {} # No subscription - - vultr.setup_platform(hass, bad_conf, add_entities, None) - - bad_conf = { - CONF_NAME: "Missing Server", - CONF_SUBSCRIPTION: "665544", - } # Sub not associated with API key (not in server_list) - - vultr.setup_platform(hass, bad_conf, add_entities, None) From 8ee2ece03ef3c5b287bab62d8ad2b9c93e69a970 Mon Sep 17 00:00:00 2001 From: Arie Catsman <120491684+catsmanac@users.noreply.github.com> Date: Fri, 3 Oct 2025 20:14:34 +0200 Subject: [PATCH 05/10] Bump pyenphase to 2.4.0 (#153583) --- homeassistant/components/enphase_envoy/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/enphase_envoy/manifest.json b/homeassistant/components/enphase_envoy/manifest.json index 0e1e89cf1e341d..a0cdda7b2b7dbf 100644 --- a/homeassistant/components/enphase_envoy/manifest.json +++ b/homeassistant/components/enphase_envoy/manifest.json @@ -7,7 +7,7 @@ "iot_class": "local_polling", "loggers": ["pyenphase"], "quality_scale": "platinum", - "requirements": ["pyenphase==2.3.0"], + "requirements": ["pyenphase==2.4.0"], "zeroconf": [ { "type": "_enphase-envoy._tcp.local." diff --git a/requirements_all.txt b/requirements_all.txt index f9538821e84a3f..ad0cd2f3a9048b 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2000,7 +2000,7 @@ pyegps==0.2.5 pyemoncms==0.1.3 # homeassistant.components.enphase_envoy -pyenphase==2.3.0 +pyenphase==2.4.0 # homeassistant.components.envisalink pyenvisalink==4.7 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index e7c0eace31d637..579bc064ee96cb 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1675,7 +1675,7 @@ pyegps==0.2.5 pyemoncms==0.1.3 # homeassistant.components.enphase_envoy -pyenphase==2.3.0 +pyenphase==2.4.0 # homeassistant.components.everlights pyeverlights==0.1.0 From ba75f18f5a5bf97625e6dc58317f22edb9d729d7 Mon Sep 17 00:00:00 2001 From: Erwin Douna Date: Fri, 3 Oct 2025 20:52:37 +0200 Subject: [PATCH 06/10] Portainer add switch platform (#153485) Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../components/portainer/__init__.py | 2 +- homeassistant/components/portainer/icons.json | 12 + .../components/portainer/strings.json | 5 + homeassistant/components/portainer/switch.py | 124 +++++++++ tests/components/portainer/conftest.py | 2 + .../portainer/snapshots/test_switch.ambr | 246 ++++++++++++++++++ tests/components/portainer/test_switch.py | 76 ++++++ 7 files changed, 466 insertions(+), 1 deletion(-) create mode 100644 homeassistant/components/portainer/icons.json create mode 100644 homeassistant/components/portainer/switch.py create mode 100644 tests/components/portainer/snapshots/test_switch.ambr create mode 100644 tests/components/portainer/test_switch.py diff --git a/homeassistant/components/portainer/__init__.py b/homeassistant/components/portainer/__init__.py index 79f7c02e4ba8be..732831b27c5e09 100644 --- a/homeassistant/components/portainer/__init__.py +++ b/homeassistant/components/portainer/__init__.py @@ -18,7 +18,7 @@ from .coordinator import PortainerCoordinator -_PLATFORMS: list[Platform] = [Platform.BINARY_SENSOR] +_PLATFORMS: list[Platform] = [Platform.BINARY_SENSOR, Platform.SWITCH] type PortainerConfigEntry = ConfigEntry[PortainerCoordinator] diff --git a/homeassistant/components/portainer/icons.json b/homeassistant/components/portainer/icons.json new file mode 100644 index 00000000000000..316851d2c67591 --- /dev/null +++ b/homeassistant/components/portainer/icons.json @@ -0,0 +1,12 @@ +{ + "entity": { + "switch": { + "container": { + "default": "mdi:arrow-down-box", + "state": { + "on": "mdi:arrow-up-box" + } + } + } + } +} diff --git a/homeassistant/components/portainer/strings.json b/homeassistant/components/portainer/strings.json index dbbfe17764f318..e48f8505277a6b 100644 --- a/homeassistant/components/portainer/strings.json +++ b/homeassistant/components/portainer/strings.json @@ -45,6 +45,11 @@ "status": { "name": "Status" } + }, + "switch": { + "container": { + "name": "Container" + } } }, "exceptions": { diff --git a/homeassistant/components/portainer/switch.py b/homeassistant/components/portainer/switch.py new file mode 100644 index 00000000000000..db0636c649d7a2 --- /dev/null +++ b/homeassistant/components/portainer/switch.py @@ -0,0 +1,124 @@ +"""Switch platform for Portainer containers.""" + +from __future__ import annotations + +from collections.abc import Callable, Coroutine +from dataclasses import dataclass +from typing import Any + +from pyportainer import Portainer +from pyportainer.models.docker import DockerContainer + +from homeassistant.components.switch import ( + SwitchDeviceClass, + SwitchEntity, + SwitchEntityDescription, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from . import PortainerConfigEntry +from .coordinator import PortainerCoordinator +from .entity import PortainerContainerEntity, PortainerCoordinatorData + + +@dataclass(frozen=True, kw_only=True) +class PortainerSwitchEntityDescription(SwitchEntityDescription): + """Class to hold Portainer switch description.""" + + is_on_fn: Callable[[DockerContainer], bool | None] + turn_on_fn: Callable[[Portainer, int, str], Coroutine[Any, Any, None]] + turn_off_fn: Callable[[Portainer, int, str], Coroutine[Any, Any, None]] + + +async def stop_container( + portainer: Portainer, endpoint_id: int, container_id: str +) -> None: + """Stop a container.""" + await portainer.stop_container(endpoint_id, container_id) + + +async def start_container( + portainer: Portainer, endpoint_id: int, container_id: str +) -> None: + """Start a container.""" + await portainer.start_container(endpoint_id, container_id) + + +SWITCHES: tuple[PortainerSwitchEntityDescription, ...] = ( + PortainerSwitchEntityDescription( + key="container", + translation_key="container", + device_class=SwitchDeviceClass.SWITCH, + is_on_fn=lambda data: data.state == "running", + turn_on_fn=start_container, + turn_off_fn=stop_container, + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: PortainerConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up Portainer switch sensors.""" + + coordinator: PortainerCoordinator = entry.runtime_data + + async_add_entities( + PortainerContainerSwitch( + coordinator=coordinator, + entity_description=entity_description, + device_info=container, + via_device=endpoint, + ) + for endpoint in coordinator.data.values() + for container in endpoint.containers.values() + for entity_description in SWITCHES + ) + + +class PortainerContainerSwitch(PortainerContainerEntity, SwitchEntity): + """Representation of a Portainer container switch.""" + + entity_description: PortainerSwitchEntityDescription + + def __init__( + self, + coordinator: PortainerCoordinator, + entity_description: PortainerSwitchEntityDescription, + device_info: DockerContainer, + via_device: PortainerCoordinatorData, + ) -> None: + """Initialize the Portainer container switch.""" + self.entity_description = entity_description + super().__init__(device_info, coordinator, via_device) + + device_identifier = ( + self._device_info.names[0].replace("/", " ").strip() + if self._device_info.names + else None + ) + self._attr_unique_id = f"{coordinator.config_entry.entry_id}_{device_identifier}_{entity_description.key}" + + @property + def is_on(self) -> bool | None: + """Return the state of the device.""" + return self.entity_description.is_on_fn( + self.coordinator.data[self.endpoint_id].containers[self.device_id] + ) + + async def async_turn_on(self, **kwargs: Any) -> None: + """Start (turn on) the container.""" + await self.entity_description.turn_on_fn( + self.coordinator.portainer, self.endpoint_id, self.device_id + ) + await self.coordinator.async_request_refresh() + + async def async_turn_off(self, **kwargs: Any) -> None: + """Stop (turn off) the container.""" + await self.entity_description.turn_off_fn( + self.coordinator.portainer, self.endpoint_id, self.device_id + ) + await self.coordinator.async_request_refresh() diff --git a/tests/components/portainer/conftest.py b/tests/components/portainer/conftest.py index 21298da10484f1..90a3fe65b15847 100644 --- a/tests/components/portainer/conftest.py +++ b/tests/components/portainer/conftest.py @@ -49,6 +49,8 @@ def mock_portainer_client() -> Generator[AsyncMock]: DockerContainer.from_dict(container) for container in load_json_array_fixture("containers.json", DOMAIN) ] + client.start_container = AsyncMock(return_value=None) + client.stop_container = AsyncMock(return_value=None) yield client diff --git a/tests/components/portainer/snapshots/test_switch.ambr b/tests/components/portainer/snapshots/test_switch.ambr new file mode 100644 index 00000000000000..6e749d8212f3d5 --- /dev/null +++ b/tests/components/portainer/snapshots/test_switch.ambr @@ -0,0 +1,246 @@ +# serializer version: 1 +# name: test_all_switch_entities_snapshot[switch.focused_einstein_container-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.focused_einstein_container', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Container', + 'platform': 'portainer', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'container', + 'unique_id': 'portainer_test_entry_123_focused_einstein_container', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_switch_entities_snapshot[switch.focused_einstein_container-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'switch', + 'friendly_name': 'focused_einstein Container', + }), + 'context': , + 'entity_id': 'switch.focused_einstein_container', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_all_switch_entities_snapshot[switch.funny_chatelet_container-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.funny_chatelet_container', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Container', + 'platform': 'portainer', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'container', + 'unique_id': 'portainer_test_entry_123_funny_chatelet_container', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_switch_entities_snapshot[switch.funny_chatelet_container-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'switch', + 'friendly_name': 'funny_chatelet Container', + }), + 'context': , + 'entity_id': 'switch.funny_chatelet_container', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_all_switch_entities_snapshot[switch.practical_morse_container-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.practical_morse_container', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Container', + 'platform': 'portainer', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'container', + 'unique_id': 'portainer_test_entry_123_practical_morse_container', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_switch_entities_snapshot[switch.practical_morse_container-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'switch', + 'friendly_name': 'practical_morse Container', + }), + 'context': , + 'entity_id': 'switch.practical_morse_container', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_all_switch_entities_snapshot[switch.serene_banach_container-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.serene_banach_container', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Container', + 'platform': 'portainer', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'container', + 'unique_id': 'portainer_test_entry_123_serene_banach_container', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_switch_entities_snapshot[switch.serene_banach_container-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'switch', + 'friendly_name': 'serene_banach Container', + }), + 'context': , + 'entity_id': 'switch.serene_banach_container', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_all_switch_entities_snapshot[switch.stoic_turing_container-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.stoic_turing_container', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Container', + 'platform': 'portainer', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'container', + 'unique_id': 'portainer_test_entry_123_stoic_turing_container', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_switch_entities_snapshot[switch.stoic_turing_container-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'switch', + 'friendly_name': 'stoic_turing Container', + }), + 'context': , + 'entity_id': 'switch.stoic_turing_container', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- diff --git a/tests/components/portainer/test_switch.py b/tests/components/portainer/test_switch.py new file mode 100644 index 00000000000000..07535bc8daf2b8 --- /dev/null +++ b/tests/components/portainer/test_switch.py @@ -0,0 +1,76 @@ +"""Tests for the Portainer switch platform.""" + +from __future__ import annotations + +from unittest.mock import AsyncMock, patch + +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.switch import ( + DOMAIN as SWITCH_DOMAIN, + SERVICE_TURN_OFF, + SERVICE_TURN_ON, +) +from homeassistant.const import ATTR_ENTITY_ID, Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import setup_integration + +from tests.common import MockConfigEntry, snapshot_platform + + +@pytest.mark.usefixtures("mock_portainer_client") +async def test_all_switch_entities_snapshot( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """Snapshot test for all Portainer switch entities.""" + with patch( + "homeassistant.components.portainer._PLATFORMS", + [Platform.SWITCH], + ): + await setup_integration(hass, mock_config_entry) + await snapshot_platform( + hass, entity_registry, snapshot, mock_config_entry.entry_id + ) + + +@pytest.mark.parametrize( + ("service_call", "client_method"), + [ + (SERVICE_TURN_ON, "start_container"), + (SERVICE_TURN_OFF, "stop_container"), + ], +) +async def test_turn_off_on( + hass: HomeAssistant, + mock_portainer_client: AsyncMock, + mock_config_entry: MockConfigEntry, + service_call: str, + client_method: str, +) -> None: + """Test the switches. Have you tried to turn it off and on again?""" + with patch( + "homeassistant.components.portainer._PLATFORMS", + [Platform.SWITCH], + ): + await setup_integration(hass, mock_config_entry) + + entity_id = "switch.practical_morse_container" + method_mock = getattr(mock_portainer_client, client_method) + + await hass.services.async_call( + SWITCH_DOMAIN, + service_call, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + + # Matches the endpoint ID and container ID + method_mock.assert_called_once_with( + 1, "ee20facfb3b3ed4cd362c1e88fc89a53908ad05fb3a4103bca3f9b28292d14bf" + ) From 66ac9078aa47cbc45345887a962177120710498e Mon Sep 17 00:00:00 2001 From: Manu <4445816+tr4nt0r@users.noreply.github.com> Date: Fri, 3 Oct 2025 20:55:38 +0200 Subject: [PATCH 07/10] Improve Habitica tests (#153573) --- .../test_image/test_image_platform.1.png | Bin 70 -> 0 bytes .../test_image/test_image_platform.png | Bin 70 -> 0 bytes tests/components/habitica/test_config_flow.py | 16 ---------------- tests/components/habitica/test_image.py | 17 ++++------------- 4 files changed, 4 insertions(+), 29 deletions(-) delete mode 100644 tests/components/habitica/__snapshots__/test_image/test_image_platform.1.png delete mode 100644 tests/components/habitica/__snapshots__/test_image/test_image_platform.png diff --git a/tests/components/habitica/__snapshots__/test_image/test_image_platform.1.png b/tests/components/habitica/__snapshots__/test_image/test_image_platform.1.png deleted file mode 100644 index 5bb8c9d9f091c7a448a220a122933a61ded065d1..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 70 zcmeAS@N?(olHy`uVBq!ia0vp^j3CUx1|;Q0k92}1TpU9xZY8HA{P-`=z|79XV4!w9 Q5h%gn>FVdQ&MBb@0IqossQ>@~ diff --git a/tests/components/habitica/__snapshots__/test_image/test_image_platform.png b/tests/components/habitica/__snapshots__/test_image/test_image_platform.png deleted file mode 100644 index 8e9b046ee05dbf00e565c46dda27eb844c562b4e..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 70 zcmeAS@N?(olHy`uVBq!ia0vp^j3CUx1|;Q0k92}1TpU9xZYBRYf8c{W11l>NL;Q)4 Qmw*xsp00i_>zopr0M?)o)c^nh diff --git a/tests/components/habitica/test_config_flow.py b/tests/components/habitica/test_config_flow.py index 63001157695c1b..a393c7a60824a9 100644 --- a/tests/components/habitica/test_config_flow.py +++ b/tests/components/habitica/test_config_flow.py @@ -87,7 +87,6 @@ async def test_form_login(hass: HomeAssistant, mock_setup_entry: AsyncMock) -> N result["flow_id"], user_input=MOCK_DATA_LOGIN_STEP, ) - await hass.async_block_till_done() assert result["type"] is FlowResultType.CREATE_ENTRY assert result["title"] == "test-user" @@ -208,7 +207,6 @@ async def test_form_advanced(hass: HomeAssistant, mock_setup_entry: AsyncMock) - result["flow_id"], user_input=MOCK_DATA_ADVANCED_STEP, ) - await hass.async_block_till_done() assert result["type"] is FlowResultType.CREATE_ENTRY assert result["title"] == "test-user" @@ -329,8 +327,6 @@ async def test_flow_reauth( user_input, ) - await hass.async_block_till_done() - assert result["type"] is FlowResultType.ABORT assert result["reason"] == "reauth_successful" assert config_entry.data[CONF_API_KEY] == "cd0e5985-17de-4b4f-849e-5d506c5e4382" @@ -399,8 +395,6 @@ async def test_flow_reauth_errors( result["flow_id"], user_input ) - await hass.async_block_till_done() - assert result["type"] is FlowResultType.FORM assert result["errors"] == {"base": text_error} @@ -412,8 +406,6 @@ async def test_flow_reauth_errors( user_input=USER_INPUT_REAUTH_API_KEY, ) - await hass.async_block_till_done() - assert result["type"] is FlowResultType.ABORT assert result["reason"] == "reauth_successful" assert config_entry.data[CONF_API_KEY] == "cd0e5985-17de-4b4f-849e-5d506c5e4382" @@ -446,8 +438,6 @@ async def test_flow_reauth_unique_id_mismatch(hass: HomeAssistant) -> None: USER_INPUT_REAUTH_LOGIN, ) - await hass.async_block_till_done() - assert result["type"] is FlowResultType.ABORT assert result["reason"] == "unique_id_mismatch" @@ -469,8 +459,6 @@ async def test_flow_reconfigure( USER_INPUT_RECONFIGURE, ) - await hass.async_block_till_done() - assert result["type"] is FlowResultType.ABORT assert result["reason"] == "reconfigure_successful" assert config_entry.data[CONF_API_KEY] == "cd0e5985-17de-4b4f-849e-5d506c5e4382" @@ -507,8 +495,6 @@ async def test_flow_reconfigure_errors( USER_INPUT_RECONFIGURE, ) - await hass.async_block_till_done() - assert result["type"] is FlowResultType.FORM assert result["errors"] == {"base": text_error} @@ -519,8 +505,6 @@ async def test_flow_reconfigure_errors( user_input=USER_INPUT_RECONFIGURE, ) - await hass.async_block_till_done() - assert result["type"] is FlowResultType.ABORT assert result["reason"] == "reconfigure_successful" assert config_entry.data[CONF_API_KEY] == "cd0e5985-17de-4b4f-849e-5d506c5e4382" diff --git a/tests/components/habitica/test_image.py b/tests/components/habitica/test_image.py index b0810d8e76f89e..d174b016e64b22 100644 --- a/tests/components/habitica/test_image.py +++ b/tests/components/habitica/test_image.py @@ -12,7 +12,6 @@ import pytest import respx from syrupy.assertion import SnapshotAssertion -from syrupy.extensions.image import PNGImageSnapshotExtension from homeassistant.components.habitica.const import ASSETS_URL, DOMAIN from homeassistant.config_entries import ConfigEntryState @@ -50,12 +49,8 @@ async def test_image_platform( "homeassistant.components.habitica.coordinator.BytesIO", ) as avatar: avatar.side_effect = [ - BytesIO( - b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x00\x01\x00\x00\x00\x01\x08\x06\x00\x00\x00\x1f\x15\xc4\x89\x00\x00\x00\rIDATx\xdac\xfc\xcf\xc0\xf0\x1f\x00\x05\x05\x02\x00_\xc8\xf1\xd2\x00\x00\x00\x00IEND\xaeB`\x82" - ), - BytesIO( - b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x00\x01\x00\x00\x00\x01\x08\x06\x00\x00\x00\x1f\x15\xc4\x89\x00\x00\x00\rIDATx\xdacd`\xf8\xff\x1f\x00\x03\x07\x02\x000&\xc7a\x00\x00\x00\x00IEND\xaeB`\x82" - ), + BytesIO(b"\x89PNGTestImage1"), + BytesIO(b"\x89PNGTestImage2"), ] config_entry.add_to_hass(hass) @@ -77,9 +72,7 @@ async def test_image_platform( resp = await client.get(state.attributes["entity_picture"]) assert resp.status == HTTPStatus.OK - assert (await resp.read()) == snapshot( - extension_class=PNGImageSnapshotExtension - ) + assert (await resp.read()) == b"\x89PNGTestImage1" habitica.get_user.return_value = HabiticaUserResponse.from_json( await async_load_fixture(hass, "rogue_fixture.json", DOMAIN) @@ -95,9 +88,7 @@ async def test_image_platform( resp = await client.get(state.attributes["entity_picture"]) assert resp.status == HTTPStatus.OK - assert (await resp.read()) == snapshot( - extension_class=PNGImageSnapshotExtension - ) + assert (await resp.read()) == b"\x89PNGTestImage2" @pytest.mark.usefixtures("habitica") From 2edf622b412a96e3362b8180627285c9eb89eb75 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 3 Oct 2025 21:15:47 +0200 Subject: [PATCH 08/10] Bump github/codeql-action from 3.30.5 to 3.30.6 (#153524) Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/codeql.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index a8081884de122d..14ee68037320a1 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -24,11 +24,11 @@ jobs: uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 - name: Initialize CodeQL - uses: github/codeql-action/init@3599b3baa15b485a2e49ef411a7a4bb2452e7f93 # v3.30.5 + uses: github/codeql-action/init@64d10c13136e1c5bce3e5fbde8d4906eeaafc885 # v3.30.6 with: languages: python - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@3599b3baa15b485a2e49ef411a7a4bb2452e7f93 # v3.30.5 + uses: github/codeql-action/analyze@64d10c13136e1c5bce3e5fbde8d4906eeaafc885 # v3.30.6 with: category: "/language:python" From ce548efd807f76d43916374fcbfa3fdce8f7282e Mon Sep 17 00:00:00 2001 From: Manu <4445816+tr4nt0r@users.noreply.github.com> Date: Fri, 3 Oct 2025 21:18:39 +0200 Subject: [PATCH 09/10] Remove IBM Watson IoT Platform integration (#153567) --- homeassistant/brands/ibm.json | 5 - .../components/watson_iot/__init__.py | 227 ------------------ .../components/watson_iot/manifest.json | 10 - homeassistant/generated/integrations.json | 23 +- requirements_all.txt | 3 - script/hassfest/quality_scale.py | 2 - 6 files changed, 6 insertions(+), 264 deletions(-) delete mode 100644 homeassistant/brands/ibm.json delete mode 100644 homeassistant/components/watson_iot/__init__.py delete mode 100644 homeassistant/components/watson_iot/manifest.json diff --git a/homeassistant/brands/ibm.json b/homeassistant/brands/ibm.json deleted file mode 100644 index 42367e899e77b8..00000000000000 --- a/homeassistant/brands/ibm.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "domain": "ibm", - "name": "IBM", - "integrations": ["watson_iot", "watson_tts"] -} diff --git a/homeassistant/components/watson_iot/__init__.py b/homeassistant/components/watson_iot/__init__.py deleted file mode 100644 index 0130b53930ba6d..00000000000000 --- a/homeassistant/components/watson_iot/__init__.py +++ /dev/null @@ -1,227 +0,0 @@ -"""Support for the IBM Watson IoT Platform.""" - -import logging -import queue -import threading -import time - -from ibmiotf import MissingMessageEncoderException -from ibmiotf.gateway import Client -import voluptuous as vol - -from homeassistant.const import ( - CONF_DOMAINS, - CONF_ENTITIES, - CONF_EXCLUDE, - CONF_ID, - CONF_INCLUDE, - CONF_TOKEN, - CONF_TYPE, - EVENT_HOMEASSISTANT_STOP, - EVENT_STATE_CHANGED, - STATE_UNAVAILABLE, - STATE_UNKNOWN, -) -from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers import config_validation as cv, state as state_helper -from homeassistant.helpers.typing import ConfigType - -_LOGGER = logging.getLogger(__name__) - -CONF_ORG = "organization" - -DOMAIN = "watson_iot" - -MAX_TRIES = 3 - -RETRY_DELAY = 20 - -CONFIG_SCHEMA = vol.Schema( - { - DOMAIN: vol.All( - vol.Schema( - { - vol.Required(CONF_ORG): cv.string, - vol.Required(CONF_TYPE): cv.string, - vol.Required(CONF_ID): cv.string, - vol.Required(CONF_TOKEN): cv.string, - vol.Optional(CONF_EXCLUDE, default={}): vol.Schema( - { - vol.Optional(CONF_ENTITIES, default=[]): cv.entity_ids, - vol.Optional(CONF_DOMAINS, default=[]): vol.All( - cv.ensure_list, [cv.string] - ), - } - ), - vol.Optional(CONF_INCLUDE, default={}): vol.Schema( - { - vol.Optional(CONF_ENTITIES, default=[]): cv.entity_ids, - vol.Optional(CONF_DOMAINS, default=[]): vol.All( - cv.ensure_list, [cv.string] - ), - } - ), - } - ) - ) - }, - extra=vol.ALLOW_EXTRA, -) - - -def setup(hass: HomeAssistant, config: ConfigType) -> bool: - """Set up the Watson IoT Platform component.""" - - conf = config[DOMAIN] - - include = conf[CONF_INCLUDE] - exclude = conf[CONF_EXCLUDE] - include_e = set(include[CONF_ENTITIES]) - include_d = set(include[CONF_DOMAINS]) - exclude_e = set(exclude[CONF_ENTITIES]) - exclude_d = set(exclude[CONF_DOMAINS]) - - client_args = { - "org": conf[CONF_ORG], - "type": conf[CONF_TYPE], - "id": conf[CONF_ID], - "auth-method": "token", - "auth-token": conf[CONF_TOKEN], - } - watson_gateway = Client(client_args) - - def event_to_json(event): - """Add an event to the outgoing list.""" - state = event.data.get("new_state") - if ( - state is None - or state.state in (STATE_UNKNOWN, "", STATE_UNAVAILABLE) - or state.entity_id in exclude_e - or state.domain in exclude_d - ): - return None - - if (include_e and state.entity_id not in include_e) or ( - include_d and state.domain not in include_d - ): - return None - - try: - _state_as_value = float(state.state) - except ValueError: - _state_as_value = None - - if _state_as_value is None: - try: - _state_as_value = float(state_helper.state_as_number(state)) - except ValueError: - _state_as_value = None - - out_event = { - "tags": {"domain": state.domain, "entity_id": state.object_id}, - "time": event.time_fired.isoformat(), - "fields": {"state": state.state}, - } - if _state_as_value is not None: - out_event["fields"]["state_value"] = _state_as_value - - for key, value in state.attributes.items(): - if key != "unit_of_measurement": - # If the key is already in fields - if key in out_event["fields"]: - key = f"{key}_" - # For each value we try to cast it as float - # But if we cannot do it we store the value - # as string - try: - out_event["fields"][key] = float(value) - except (ValueError, TypeError): - out_event["fields"][key] = str(value) - - return out_event - - instance = hass.data[DOMAIN] = WatsonIOTThread(hass, watson_gateway, event_to_json) - instance.start() - - def shutdown(event): - """Shut down the thread.""" - instance.queue.put(None) - instance.join() - - hass.bus.listen_once(EVENT_HOMEASSISTANT_STOP, shutdown) - - return True - - -class WatsonIOTThread(threading.Thread): - """A threaded event handler class.""" - - def __init__(self, hass, gateway, event_to_json): - """Initialize the listener.""" - threading.Thread.__init__(self, name="WatsonIOT") - self.queue = queue.Queue() - self.gateway = gateway - self.gateway.connect() - self.event_to_json = event_to_json - self.write_errors = 0 - self.shutdown = False - hass.bus.listen(EVENT_STATE_CHANGED, self._event_listener) - - @callback - def _event_listener(self, event): - """Listen for new messages on the bus and queue them for Watson IoT.""" - item = (time.monotonic(), event) - self.queue.put(item) - - def get_events_json(self): - """Return an event formatted for writing.""" - events = [] - - try: - if (item := self.queue.get()) is None: - self.shutdown = True - else: - event_json = self.event_to_json(item[1]) - if event_json: - events.append(event_json) - - except queue.Empty: - pass - - return events - - def write_to_watson(self, events): - """Write preprocessed events to watson.""" - - for event in events: - for retry in range(MAX_TRIES + 1): - try: - for field in event["fields"]: - value = event["fields"][field] - device_success = self.gateway.publishDeviceEvent( - event["tags"]["domain"], - event["tags"]["entity_id"], - field, - "json", - value, - ) - if not device_success: - _LOGGER.error("Failed to publish message to Watson IoT") - continue - break - except (MissingMessageEncoderException, OSError): - if retry < MAX_TRIES: - time.sleep(RETRY_DELAY) - else: - _LOGGER.exception("Failed to publish message to Watson IoT") - - def run(self): - """Process incoming events.""" - while not self.shutdown: - if event := self.get_events_json(): - self.write_to_watson(event) - self.queue.task_done() - - def block_till_done(self): - """Block till all events processed.""" - self.queue.join() diff --git a/homeassistant/components/watson_iot/manifest.json b/homeassistant/components/watson_iot/manifest.json deleted file mode 100644 index a457dcc44b1beb..00000000000000 --- a/homeassistant/components/watson_iot/manifest.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "domain": "watson_iot", - "name": "IBM Watson IoT Platform", - "codeowners": [], - "documentation": "https://www.home-assistant.io/integrations/watson_iot", - "iot_class": "cloud_push", - "loggers": ["ibmiotf", "paho_mqtt"], - "quality_scale": "legacy", - "requirements": ["ibmiotf==0.3.4"] -} diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index ef6dfdfc823fca..bd3cd7692c990e 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -2923,23 +2923,6 @@ "iot_class": "cloud_polling", "single_config_entry": true }, - "ibm": { - "name": "IBM", - "integrations": { - "watson_iot": { - "integration_type": "hub", - "config_flow": false, - "iot_class": "cloud_push", - "name": "IBM Watson IoT Platform" - }, - "watson_tts": { - "integration_type": "hub", - "config_flow": false, - "iot_class": "cloud_push", - "name": "IBM Watson TTS" - } - } - }, "idteck_prox": { "name": "IDTECK Proximity Reader", "integration_type": "hub", @@ -7447,6 +7430,12 @@ "config_flow": true, "iot_class": "local_push" }, + "watson_tts": { + "name": "IBM Watson TTS", + "integration_type": "hub", + "config_flow": false, + "iot_class": "cloud_push" + }, "watttime": { "name": "WattTime", "integration_type": "service", diff --git a/requirements_all.txt b/requirements_all.txt index ad0cd2f3a9048b..7c69d19303abdd 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1218,9 +1218,6 @@ iaqualink==0.6.0 # homeassistant.components.ibeacon ibeacon-ble==1.2.0 -# homeassistant.components.watson_iot -ibmiotf==0.3.4 - # homeassistant.components.google # homeassistant.components.local_calendar # homeassistant.components.local_todo diff --git a/script/hassfest/quality_scale.py b/script/hassfest/quality_scale.py index 97c8a63a1f6837..7468afab89045d 100644 --- a/script/hassfest/quality_scale.py +++ b/script/hassfest/quality_scale.py @@ -1067,7 +1067,6 @@ class Rule: "wallbox", "waqi", "waterfurnace", - "watson_iot", "watson_tts", "watttime", "waze_travel_time", @@ -2116,7 +2115,6 @@ class Rule: "wallbox", "waqi", "waterfurnace", - "watson_iot", "watson_tts", "watttime", "waze_travel_time", From 80a4115c44e807db57e21a796f97e01beb2ce298 Mon Sep 17 00:00:00 2001 From: Erwin Douna Date: Fri, 3 Oct 2025 22:36:55 +0200 Subject: [PATCH 10/10] Portainer follow-up points (#153594) --- .../components/portainer/binary_sensor.py | 10 +-- homeassistant/components/portainer/entity.py | 16 ++--- homeassistant/components/portainer/switch.py | 63 ++++++++++++------- tests/components/portainer/test_switch.py | 50 +++++++++++++++ 4 files changed, 99 insertions(+), 40 deletions(-) diff --git a/homeassistant/components/portainer/binary_sensor.py b/homeassistant/components/portainer/binary_sensor.py index 543bdeaf335d41..032b46ef8b45c7 100644 --- a/homeassistant/components/portainer/binary_sensor.py +++ b/homeassistant/components/portainer/binary_sensor.py @@ -131,15 +131,7 @@ def __init__( self.entity_description = entity_description super().__init__(device_info, coordinator, via_device) - # Container ID's are ephemeral, so use the container name for the unique ID - # The first one, should always be unique, it's fine if users have aliases - # According to Docker's API docs, the first name is unique - device_identifier = ( - self._device_info.names[0].replace("/", " ").strip() - if self._device_info.names - else None - ) - self._attr_unique_id = f"{coordinator.config_entry.entry_id}_{device_identifier}_{entity_description.key}" + self._attr_unique_id = f"{coordinator.config_entry.entry_id}_{self.device_name}_{entity_description.key}" @property def available(self) -> bool: diff --git a/homeassistant/components/portainer/entity.py b/homeassistant/components/portainer/entity.py index 907e8cf4afe900..27355bb7c0c799 100644 --- a/homeassistant/components/portainer/entity.py +++ b/homeassistant/components/portainer/entity.py @@ -57,25 +57,25 @@ def __init__( self.device_id = self._device_info.id self.endpoint_id = via_device.endpoint.id - device_name = ( - self._device_info.names[0].replace("/", " ").strip() - if self._device_info.names - else None - ) + # Container ID's are ephemeral, so use the container name for the unique ID + # The first one, should always be unique, it's fine if users have aliases + # According to Docker's API docs, the first name is unique + assert self._device_info.names, "Container names list unexpectedly empty" + self.device_name = self._device_info.names[0].replace("/", " ").strip() self._attr_device_info = DeviceInfo( identifiers={ - (DOMAIN, f"{self.coordinator.config_entry.entry_id}_{device_name}") + (DOMAIN, f"{self.coordinator.config_entry.entry_id}_{self.device_name}") }, manufacturer=DEFAULT_NAME, configuration_url=URL( f"{coordinator.config_entry.data[CONF_URL]}#!/{self.endpoint_id}/docker/containers/{self.device_id}" ), model="Container", - name=device_name, + name=self.device_name, via_device=( DOMAIN, f"{self.coordinator.config_entry.entry_id}_{self.endpoint_id}", ), - translation_key=None if device_name else "unknown_container", + translation_key=None if self.device_name else "unknown_container", ) diff --git a/homeassistant/components/portainer/switch.py b/homeassistant/components/portainer/switch.py index db0636c649d7a2..eed33e43c0cd0b 100644 --- a/homeassistant/components/portainer/switch.py +++ b/homeassistant/components/portainer/switch.py @@ -7,6 +7,11 @@ from typing import Any from pyportainer import Portainer +from pyportainer.exceptions import ( + PortainerAuthenticationError, + PortainerConnectionError, + PortainerTimeoutError, +) from pyportainer.models.docker import DockerContainer from homeassistant.components.switch import ( @@ -15,9 +20,11 @@ SwitchEntityDescription, ) from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import PortainerConfigEntry +from .const import DOMAIN from .coordinator import PortainerCoordinator from .entity import PortainerContainerEntity, PortainerCoordinatorData @@ -27,22 +34,37 @@ class PortainerSwitchEntityDescription(SwitchEntityDescription): """Class to hold Portainer switch description.""" is_on_fn: Callable[[DockerContainer], bool | None] - turn_on_fn: Callable[[Portainer, int, str], Coroutine[Any, Any, None]] - turn_off_fn: Callable[[Portainer, int, str], Coroutine[Any, Any, None]] + turn_on_fn: Callable[[str, Portainer, int, str], Coroutine[Any, Any, None]] + turn_off_fn: Callable[[str, Portainer, int, str], Coroutine[Any, Any, None]] -async def stop_container( - portainer: Portainer, endpoint_id: int, container_id: str +async def perform_action( + action: str, portainer: Portainer, endpoint_id: int, container_id: str ) -> None: """Stop a container.""" - await portainer.stop_container(endpoint_id, container_id) - - -async def start_container( - portainer: Portainer, endpoint_id: int, container_id: str -) -> None: - """Start a container.""" - await portainer.start_container(endpoint_id, container_id) + try: + if action == "start": + await portainer.start_container(endpoint_id, container_id) + elif action == "stop": + await portainer.stop_container(endpoint_id, container_id) + except PortainerAuthenticationError as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="invalid_auth", + translation_placeholders={"error": repr(err)}, + ) from err + except PortainerConnectionError as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="cannot_connect", + translation_placeholders={"error": repr(err)}, + ) from err + except PortainerTimeoutError as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="timeout_connect", + translation_placeholders={"error": repr(err)}, + ) from err SWITCHES: tuple[PortainerSwitchEntityDescription, ...] = ( @@ -51,8 +73,8 @@ async def start_container( translation_key="container", device_class=SwitchDeviceClass.SWITCH, is_on_fn=lambda data: data.state == "running", - turn_on_fn=start_container, - turn_off_fn=stop_container, + turn_on_fn=perform_action, + turn_off_fn=perform_action, ), ) @@ -64,7 +86,7 @@ async def async_setup_entry( ) -> None: """Set up Portainer switch sensors.""" - coordinator: PortainerCoordinator = entry.runtime_data + coordinator = entry.runtime_data async_add_entities( PortainerContainerSwitch( @@ -95,12 +117,7 @@ def __init__( self.entity_description = entity_description super().__init__(device_info, coordinator, via_device) - device_identifier = ( - self._device_info.names[0].replace("/", " ").strip() - if self._device_info.names - else None - ) - self._attr_unique_id = f"{coordinator.config_entry.entry_id}_{device_identifier}_{entity_description.key}" + self._attr_unique_id = f"{coordinator.config_entry.entry_id}_{self.device_name}_{entity_description.key}" @property def is_on(self) -> bool | None: @@ -112,13 +129,13 @@ def is_on(self) -> bool | None: async def async_turn_on(self, **kwargs: Any) -> None: """Start (turn on) the container.""" await self.entity_description.turn_on_fn( - self.coordinator.portainer, self.endpoint_id, self.device_id + "start", self.coordinator.portainer, self.endpoint_id, self.device_id ) await self.coordinator.async_request_refresh() async def async_turn_off(self, **kwargs: Any) -> None: """Stop (turn off) the container.""" await self.entity_description.turn_off_fn( - self.coordinator.portainer, self.endpoint_id, self.device_id + "stop", self.coordinator.portainer, self.endpoint_id, self.device_id ) await self.coordinator.async_request_refresh() diff --git a/tests/components/portainer/test_switch.py b/tests/components/portainer/test_switch.py index 07535bc8daf2b8..c738c1a264ff85 100644 --- a/tests/components/portainer/test_switch.py +++ b/tests/components/portainer/test_switch.py @@ -4,6 +4,11 @@ from unittest.mock import AsyncMock, patch +from pyportainer.exceptions import ( + PortainerAuthenticationError, + PortainerConnectionError, + PortainerTimeoutError, +) import pytest from syrupy.assertion import SnapshotAssertion @@ -14,6 +19,7 @@ ) from homeassistant.const import ATTR_ENTITY_ID, Platform from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_registry as er from . import setup_integration @@ -74,3 +80,47 @@ async def test_turn_off_on( method_mock.assert_called_once_with( 1, "ee20facfb3b3ed4cd362c1e88fc89a53908ad05fb3a4103bca3f9b28292d14bf" ) + + +@pytest.mark.parametrize( + ("service_call", "client_method"), + [ + (SERVICE_TURN_ON, "start_container"), + (SERVICE_TURN_OFF, "stop_container"), + ], +) +@pytest.mark.parametrize( + ("raise_exception", "expected_exception"), + [ + (PortainerAuthenticationError, HomeAssistantError), + (PortainerConnectionError, HomeAssistantError), + (PortainerTimeoutError, HomeAssistantError), + ], +) +async def test_turn_off_on_exceptions( + hass: HomeAssistant, + mock_portainer_client: AsyncMock, + mock_config_entry: MockConfigEntry, + service_call: str, + client_method: str, + raise_exception: Exception, + expected_exception: Exception, +) -> None: + """Test the switches. Have you tried to turn it off and on again? This time they will do boom!""" + with patch( + "homeassistant.components.portainer._PLATFORMS", + [Platform.SWITCH], + ): + await setup_integration(hass, mock_config_entry) + + entity_id = "switch.practical_morse_container" + method_mock = getattr(mock_portainer_client, client_method) + + method_mock.side_effect = raise_exception + with pytest.raises(expected_exception): + await hass.services.async_call( + SWITCH_DOMAIN, + service_call, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + )