diff --git a/.coveragerc b/.coveragerc index be3e31bf72f..bb52648710f 100644 --- a/.coveragerc +++ b/.coveragerc @@ -1440,7 +1440,6 @@ omit = homeassistant/components/thinkingcleaner/* homeassistant/components/thomson/device_tracker.py homeassistant/components/tibber/__init__.py - homeassistant/components/tibber/notify.py homeassistant/components/tibber/sensor.py homeassistant/components/tikteck/light.py homeassistant/components/tile/__init__.py diff --git a/homeassistant/components/tibber/__init__.py b/homeassistant/components/tibber/__init__.py index 7305cf835c5..1de70389114 100644 --- a/homeassistant/components/tibber/__init__.py +++ b/homeassistant/components/tibber/__init__.py @@ -22,7 +22,7 @@ from homeassistant.util import dt as dt_util from .const import DATA_HASS_CONFIG, DOMAIN -PLATFORMS = [Platform.SENSOR] +PLATFORMS = [Platform.NOTIFY, Platform.SENSOR] CONFIG_SCHEMA = cv.removed(DOMAIN, raise_if_present=False) @@ -68,8 +68,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) - # set up notify platform, no entry support for notify component yet, - # have to use discovery to load platform. + # Use discovery to load platform legacy notify platform + # The use of the legacy notify service was deprecated with HA Core 2024.6 + # Support will be removed with HA Core 2024.12 hass.async_create_task( discovery.async_load_platform( hass, @@ -79,6 +80,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: hass.data[DATA_HASS_CONFIG], ) ) + return True diff --git a/homeassistant/components/tibber/notify.py b/homeassistant/components/tibber/notify.py index b0816de39e2..24ae86c9e7f 100644 --- a/homeassistant/components/tibber/notify.py +++ b/homeassistant/components/tibber/notify.py @@ -3,21 +3,26 @@ from __future__ import annotations from collections.abc import Callable -import logging from typing import Any +from tibber import Tibber + from homeassistant.components.notify import ( ATTR_TITLE, ATTR_TITLE_DEFAULT, BaseNotificationService, + NotifyEntity, + NotifyEntityFeature, + migrate_notify_issue, ) +from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from . import DOMAIN as TIBBER_DOMAIN -_LOGGER = logging.getLogger(__name__) - async def async_get_service( hass: HomeAssistant, @@ -25,10 +30,17 @@ async def async_get_service( discovery_info: DiscoveryInfoType | None = None, ) -> TibberNotificationService: """Get the Tibber notification service.""" - tibber_connection = hass.data[TIBBER_DOMAIN] + tibber_connection: Tibber = hass.data[TIBBER_DOMAIN] return TibberNotificationService(tibber_connection.send_notification) +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Set up the Tibber notification entity.""" + async_add_entities([TibberNotificationEntity(entry.entry_id)]) + + class TibberNotificationService(BaseNotificationService): """Implement the notification service for Tibber.""" @@ -38,8 +50,35 @@ class TibberNotificationService(BaseNotificationService): async def async_send_message(self, message: str = "", **kwargs: Any) -> None: """Send a message to Tibber devices.""" + migrate_notify_issue(self.hass, TIBBER_DOMAIN, "Tibber", "2024.12.0") title = kwargs.get(ATTR_TITLE, ATTR_TITLE_DEFAULT) try: await self._notify(title=title, message=message) - except TimeoutError: - _LOGGER.error("Timeout sending message with Tibber") + except TimeoutError as exc: + raise HomeAssistantError( + translation_domain=TIBBER_DOMAIN, translation_key="send_message_timeout" + ) from exc + + +class TibberNotificationEntity(NotifyEntity): + """Implement the notification entity service for Tibber.""" + + _attr_supported_features = NotifyEntityFeature.TITLE + _attr_name = TIBBER_DOMAIN + _attr_icon = "mdi:message-flash" + + def __init__(self, unique_id: str) -> None: + """Initialize Tibber notify entity.""" + self._attr_unique_id = unique_id + + async def async_send_message(self, message: str, title: str | None = None) -> None: + """Send a message to Tibber devices.""" + tibber_connection: Tibber = self.hass.data[TIBBER_DOMAIN] + try: + await tibber_connection.send_notification( + title or ATTR_TITLE_DEFAULT, message + ) + except TimeoutError as exc: + raise HomeAssistantError( + translation_domain=TIBBER_DOMAIN, translation_key="send_message_timeout" + ) from exc diff --git a/homeassistant/components/tibber/strings.json b/homeassistant/components/tibber/strings.json index af14c96674d..7647dcb9e9a 100644 --- a/homeassistant/components/tibber/strings.json +++ b/homeassistant/components/tibber/strings.json @@ -101,5 +101,10 @@ "description": "Enter your access token from {url}" } } + }, + "exceptions": { + "send_message_timeout": { + "message": "Timeout sending message with Tibber" + } } } diff --git a/tests/components/tibber/conftest.py b/tests/components/tibber/conftest.py index da3f3df1bd9..fc6596444c5 100644 --- a/tests/components/tibber/conftest.py +++ b/tests/components/tibber/conftest.py @@ -1,15 +1,19 @@ """Test helpers for Tibber.""" +from collections.abc import AsyncGenerator +from unittest.mock import AsyncMock, MagicMock, PropertyMock, patch + import pytest from homeassistant.components.tibber.const import DOMAIN from homeassistant.const import CONF_ACCESS_TOKEN +from homeassistant.core import HomeAssistant from tests.common import MockConfigEntry @pytest.fixture -def config_entry(hass): +def config_entry(hass: HomeAssistant) -> MockConfigEntry: """Tibber config entry.""" config_entry = MockConfigEntry( domain=DOMAIN, @@ -18,3 +22,24 @@ def config_entry(hass): ) config_entry.add_to_hass(hass) return config_entry + + +@pytest.fixture +async def mock_tibber_setup( + config_entry: MockConfigEntry, hass: HomeAssistant +) -> AsyncGenerator[None, MagicMock]: + """Mock tibber entry setup.""" + unique_user_id = "unique_user_id" + title = "title" + + tibber_mock = MagicMock() + tibber_mock.update_info = AsyncMock(return_value=True) + tibber_mock.user_id = PropertyMock(return_value=unique_user_id) + tibber_mock.name = PropertyMock(return_value=title) + tibber_mock.send_notification = AsyncMock() + tibber_mock.rt_disconnect = AsyncMock() + + with patch("tibber.Tibber", return_value=tibber_mock): + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + yield tibber_mock diff --git a/tests/components/tibber/test_init.py b/tests/components/tibber/test_init.py new file mode 100644 index 00000000000..dcc23307050 --- /dev/null +++ b/tests/components/tibber/test_init.py @@ -0,0 +1,21 @@ +"""Test loading of the Tibber config entry.""" + +from unittest.mock import MagicMock + +from homeassistant.components.recorder import Recorder +from homeassistant.components.tibber import DOMAIN +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant + + +async def test_entry_unload( + recorder_mock: Recorder, hass: HomeAssistant, mock_tibber_setup: MagicMock +) -> None: + """Test unloading the entry.""" + entry = hass.config_entries.async_entry_for_domain_unique_id(DOMAIN, "tibber") + assert entry.state == ConfigEntryState.LOADED + + await hass.config_entries.async_unload(entry.entry_id) + mock_tibber_setup.rt_disconnect.assert_called_once() + await hass.async_block_till_done(wait_background_tasks=True) + assert entry.state == ConfigEntryState.NOT_LOADED diff --git a/tests/components/tibber/test_notify.py b/tests/components/tibber/test_notify.py new file mode 100644 index 00000000000..2e157e9415a --- /dev/null +++ b/tests/components/tibber/test_notify.py @@ -0,0 +1,61 @@ +"""Tests for tibber notification service.""" + +from asyncio import TimeoutError +from unittest.mock import MagicMock + +import pytest + +from homeassistant.components.recorder import Recorder +from homeassistant.components.tibber import DOMAIN +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError + + +async def test_notification_services( + recorder_mock: Recorder, hass: HomeAssistant, mock_tibber_setup: MagicMock +) -> None: + """Test create entry from user input.""" + # Assert notify entity has been added + notify_state = hass.states.get("notify.tibber") + assert notify_state is not None + + # Assert legacy notify service hass been added + assert hass.services.has_service("notify", DOMAIN) + + # Test legacy notify service + service = "tibber" + service_data = {"message": "The message", "title": "A title"} + await hass.services.async_call("notify", service, service_data, blocking=True) + calls: MagicMock = mock_tibber_setup.send_notification + + calls.assert_called_once_with(message="The message", title="A title") + calls.reset_mock() + + # Test notify entity service + service = "send_message" + service_data = { + "entity_id": "notify.tibber", + "message": "The message", + "title": "A title", + } + await hass.services.async_call("notify", service, service_data, blocking=True) + calls.assert_called_once_with("A title", "The message") + calls.reset_mock() + + calls.side_effect = TimeoutError + + with pytest.raises(HomeAssistantError): + # Test legacy notify service + service = "tibber" + service_data = {"message": "The message", "title": "A title"} + await hass.services.async_call("notify", service, service_data, blocking=True) + + with pytest.raises(HomeAssistantError): + # Test notify entity service + service = "send_message" + service_data = { + "entity_id": "notify.tibber", + "message": "The message", + "title": "A title", + } + await hass.services.async_call("notify", service, service_data, blocking=True) diff --git a/tests/components/tibber/test_repairs.py b/tests/components/tibber/test_repairs.py new file mode 100644 index 00000000000..9aaec81618d --- /dev/null +++ b/tests/components/tibber/test_repairs.py @@ -0,0 +1,66 @@ +"""Test loading of the Tibber config entry.""" + +from http import HTTPStatus +from unittest.mock import MagicMock + +from homeassistant.components.recorder import Recorder +from homeassistant.components.repairs.websocket_api import ( + RepairsFlowIndexView, + RepairsFlowResourceView, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers import issue_registry as ir + +from tests.typing import ClientSessionGenerator + + +async def test_repair_flow( + recorder_mock: Recorder, + hass: HomeAssistant, + issue_registry: ir.IssueRegistry, + mock_tibber_setup: MagicMock, + hass_client: ClientSessionGenerator, +) -> None: + """Test unloading the entry.""" + + # Test legacy notify service + service = "tibber" + service_data = {"message": "The message", "title": "A title"} + await hass.services.async_call("notify", service, service_data, blocking=True) + calls: MagicMock = mock_tibber_setup.send_notification + + calls.assert_called_once_with(message="The message", title="A title") + calls.reset_mock() + + http_client = await hass_client() + # Assert the issue is present + assert issue_registry.async_get_issue( + domain="notify", + issue_id="migrate_notify_tibber", + ) + assert len(issue_registry.issues) == 1 + + url = RepairsFlowIndexView.url + resp = await http_client.post( + url, json={"handler": "notify", "issue_id": "migrate_notify_tibber"} + ) + assert resp.status == HTTPStatus.OK + data = await resp.json() + + flow_id = data["flow_id"] + assert data["step_id"] == "confirm" + + # Simulate the users confirmed the repair flow + url = RepairsFlowResourceView.url.format(flow_id=flow_id) + resp = await http_client.post(url) + assert resp.status == HTTPStatus.OK + data = await resp.json() + assert data["type"] == "create_entry" + await hass.async_block_till_done() + + # Assert the issue is no longer present + assert not issue_registry.async_get_issue( + domain="notify", + issue_id="migrate_notify_tibber", + ) + assert len(issue_registry.issues) == 0