diff --git a/.coveragerc b/.coveragerc index 0e7a7324064..928f4d7789e 100644 --- a/.coveragerc +++ b/.coveragerc @@ -1065,6 +1065,7 @@ omit = homeassistant/components/shelly/sensor.py homeassistant/components/shelly/utils.py homeassistant/components/sigfox/sensor.py + homeassistant/components/simplepush/__init__.py homeassistant/components/simplepush/notify.py homeassistant/components/simplisafe/__init__.py homeassistant/components/simplisafe/alarm_control_panel.py diff --git a/CODEOWNERS b/CODEOWNERS index 9de118552aa..e2d0cbdaa3f 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -929,6 +929,8 @@ build.json @home-assistant/supervisor /tests/components/sighthound/ @robmarkcole /homeassistant/components/signal_messenger/ @bbernhard /tests/components/signal_messenger/ @bbernhard +/homeassistant/components/simplepush/ @engrbm87 +/tests/components/simplepush/ @engrbm87 /homeassistant/components/simplisafe/ @bachya /tests/components/simplisafe/ @bachya /homeassistant/components/sinch/ @bendikrb diff --git a/homeassistant/components/simplepush/__init__.py b/homeassistant/components/simplepush/__init__.py index 8253cfad8b4..c5782258cb7 100644 --- a/homeassistant/components/simplepush/__init__.py +++ b/homeassistant/components/simplepush/__init__.py @@ -1 +1,39 @@ """The simplepush component.""" + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import discovery +from homeassistant.helpers.typing import ConfigType + +from .const import DATA_HASS_CONFIG, DOMAIN + +PLATFORMS = [Platform.NOTIFY] + + +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: + """Set up the simplepush component.""" + + hass.data[DATA_HASS_CONFIG] = config + return True + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up simplepush from a config entry.""" + + hass.async_create_task( + discovery.async_load_platform( + hass, + Platform.NOTIFY, + DOMAIN, + dict(entry.data), + hass.data[DATA_HASS_CONFIG], + ) + ) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload a config entry.""" + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/simplepush/config_flow.py b/homeassistant/components/simplepush/config_flow.py new file mode 100644 index 00000000000..cf08a341114 --- /dev/null +++ b/homeassistant/components/simplepush/config_flow.py @@ -0,0 +1,88 @@ +"""Config flow for simplepush integration.""" +from __future__ import annotations + +import logging +from typing import Any + +from simplepush import UnknownError, send, send_encrypted +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.const import CONF_NAME, CONF_PASSWORD +from homeassistant.data_entry_flow import FlowResult + +from .const import ATTR_ENCRYPTED, CONF_DEVICE_KEY, CONF_SALT, DEFAULT_NAME, DOMAIN + +_LOGGER = logging.getLogger(__name__) + + +def validate_input(entry: dict[str, str]) -> dict[str, str] | None: + """Validate user input.""" + try: + if CONF_PASSWORD in entry: + send_encrypted( + entry[CONF_DEVICE_KEY], + entry[CONF_PASSWORD], + entry[CONF_PASSWORD], + "HA test", + "Message delivered successfully", + ) + else: + send(entry[CONF_DEVICE_KEY], "HA test", "Message delivered successfully") + except UnknownError: + return {"base": "cannot_connect"} + + return None + + +class SimplePushFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a config flow for simplepush.""" + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle a flow initiated by the user.""" + errors: dict[str, str] | None = None + if user_input is not None: + + await self.async_set_unique_id(user_input[CONF_DEVICE_KEY]) + self._abort_if_unique_id_configured() + + self._async_abort_entries_match( + { + CONF_NAME: user_input[CONF_NAME], + } + ) + + if not ( + errors := await self.hass.async_add_executor_job( + validate_input, user_input + ) + ): + return self.async_create_entry( + title=user_input[CONF_NAME], + data=user_input, + ) + + return self.async_show_form( + step_id="user", + data_schema=vol.Schema( + { + vol.Required(CONF_DEVICE_KEY): str, + vol.Required(CONF_NAME, default=DEFAULT_NAME): str, + vol.Inclusive(CONF_PASSWORD, ATTR_ENCRYPTED): str, + vol.Inclusive(CONF_SALT, ATTR_ENCRYPTED): str, + } + ), + errors=errors, + ) + + async def async_step_import(self, import_config: dict[str, str]) -> FlowResult: + """Import a config entry from configuration.yaml.""" + _LOGGER.warning( + "Configuration of the simplepush integration in YAML is deprecated and " + "will be removed in a future release; Your existing configuration " + "has been imported into the UI automatically and can be safely removed " + "from your configuration.yaml file" + ) + return await self.async_step_user(import_config) diff --git a/homeassistant/components/simplepush/const.py b/homeassistant/components/simplepush/const.py new file mode 100644 index 00000000000..6195a5fd1d9 --- /dev/null +++ b/homeassistant/components/simplepush/const.py @@ -0,0 +1,13 @@ +"""Constants for the simplepush integration.""" + +from typing import Final + +DOMAIN: Final = "simplepush" +DEFAULT_NAME: Final = "simplepush" +DATA_HASS_CONFIG: Final = "simplepush_hass_config" + +ATTR_ENCRYPTED: Final = "encrypted" +ATTR_EVENT: Final = "event" + +CONF_DEVICE_KEY: Final = "device_key" +CONF_SALT: Final = "salt" diff --git a/homeassistant/components/simplepush/manifest.json b/homeassistant/components/simplepush/manifest.json index 26321d17aef..7c37546485a 100644 --- a/homeassistant/components/simplepush/manifest.json +++ b/homeassistant/components/simplepush/manifest.json @@ -3,7 +3,8 @@ "name": "Simplepush", "documentation": "https://www.home-assistant.io/integrations/simplepush", "requirements": ["simplepush==1.1.4"], - "codeowners": [], + "codeowners": ["@engrbm87"], + "config_flow": true, "iot_class": "cloud_polling", "loggers": ["simplepush"] } diff --git a/homeassistant/components/simplepush/notify.py b/homeassistant/components/simplepush/notify.py index 5a83dec69f0..e9cd9813175 100644 --- a/homeassistant/components/simplepush/notify.py +++ b/homeassistant/components/simplepush/notify.py @@ -1,5 +1,10 @@ """Simplepush notification service.""" -from simplepush import send, send_encrypted +from __future__ import annotations + +import logging +from typing import Any + +from simplepush import BadRequest, UnknownError, send, send_encrypted import voluptuous as vol from homeassistant.components.notify import ( @@ -8,14 +13,16 @@ from homeassistant.components.notify import ( PLATFORM_SCHEMA, BaseNotificationService, ) +from homeassistant.components.notify.const import ATTR_DATA +from homeassistant.config_entries import SOURCE_IMPORT from homeassistant.const import CONF_EVENT, CONF_PASSWORD +from homeassistant.core import HomeAssistant import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -ATTR_ENCRYPTED = "encrypted" - -CONF_DEVICE_KEY = "device_key" -CONF_SALT = "salt" +from .const import ATTR_ENCRYPTED, ATTR_EVENT, CONF_DEVICE_KEY, CONF_SALT, DOMAIN +# Configuring simplepush under the notify platform will be removed in 2022.9.0 PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( { vol.Required(CONF_DEVICE_KEY): cv.string, @@ -25,34 +32,62 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( } ) +_LOGGER = logging.getLogger(__name__) -def get_service(hass, config, discovery_info=None): + +async def async_get_service( + hass: HomeAssistant, + config: ConfigType, + discovery_info: DiscoveryInfoType | None = None, +) -> SimplePushNotificationService | None: """Get the Simplepush notification service.""" - return SimplePushNotificationService(config) + if discovery_info is None: + hass.async_create_task( + hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_IMPORT}, data=config + ) + ) + return None + + return SimplePushNotificationService(discovery_info) class SimplePushNotificationService(BaseNotificationService): """Implementation of the notification service for Simplepush.""" - def __init__(self, config): + def __init__(self, config: dict[str, Any]) -> None: """Initialize the Simplepush notification service.""" - self._device_key = config.get(CONF_DEVICE_KEY) - self._event = config.get(CONF_EVENT) - self._password = config.get(CONF_PASSWORD) - self._salt = config.get(CONF_SALT) + self._device_key: str = config[CONF_DEVICE_KEY] + self._event: str | None = config.get(CONF_EVENT) + self._password: str | None = config.get(CONF_PASSWORD) + self._salt: str | None = config.get(CONF_SALT) - def send_message(self, message="", **kwargs): + def send_message(self, message: str, **kwargs: Any) -> None: """Send a message to a Simplepush user.""" title = kwargs.get(ATTR_TITLE, ATTR_TITLE_DEFAULT) - if self._password: - send_encrypted( - self._device_key, - self._password, - self._salt, - title, - message, - event=self._event, - ) - else: - send(self._device_key, title, message, event=self._event) + # event can now be passed in the service data + event = None + if data := kwargs.get(ATTR_DATA): + event = data.get(ATTR_EVENT) + + # use event from config until YAML config is removed + event = event or self._event + + try: + if self._password: + send_encrypted( + self._device_key, + self._password, + self._salt, + title, + message, + event=event, + ) + else: + send(self._device_key, title, message, event=event) + + except BadRequest: + _LOGGER.error("Bad request. Title or message are too long") + except UnknownError: + _LOGGER.error("Failed to send the notification") diff --git a/homeassistant/components/simplepush/strings.json b/homeassistant/components/simplepush/strings.json new file mode 100644 index 00000000000..0031dc32340 --- /dev/null +++ b/homeassistant/components/simplepush/strings.json @@ -0,0 +1,21 @@ +{ + "config": { + "step": { + "user": { + "data": { + "name": "[%key:common::config_flow::data::name%]", + "device_key": "The device key of your device", + "event": "The event for the events.", + "password": "The password of the encryption used by your device", + "salt": "The salt used by your device." + } + } + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]" + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" + } + } +} diff --git a/homeassistant/components/simplepush/translations/en.json b/homeassistant/components/simplepush/translations/en.json new file mode 100644 index 00000000000..a36a3b2b273 --- /dev/null +++ b/homeassistant/components/simplepush/translations/en.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "already_configured": "Device is already configured" + }, + "error": { + "cannot_connect": "Failed to connect" + }, + "step": { + "user": { + "data": { + "device_key": "The device key of your device", + "event": "The event for the events.", + "name": "Name", + "password": "The password of the encryption used by your device", + "salt": "The salt used by your device." + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 6b26b2a99b7..3c6ad94a21f 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -311,6 +311,7 @@ FLOWS = { "shelly", "shopping_list", "sia", + "simplepush", "simplisafe", "skybell", "slack", diff --git a/requirements_test_all.txt b/requirements_test_all.txt index b41bf1b223d..a8fd3b39dbf 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1430,6 +1430,9 @@ sharkiq==0.0.1 # homeassistant.components.sighthound simplehound==0.3 +# homeassistant.components.simplepush +simplepush==1.1.4 + # homeassistant.components.simplisafe simplisafe-python==2022.06.0 diff --git a/tests/components/simplepush/__init__.py b/tests/components/simplepush/__init__.py new file mode 100644 index 00000000000..fd40577f8fa --- /dev/null +++ b/tests/components/simplepush/__init__.py @@ -0,0 +1 @@ +"""Tests for the simeplush integration.""" diff --git a/tests/components/simplepush/test_config_flow.py b/tests/components/simplepush/test_config_flow.py new file mode 100644 index 00000000000..4636df6b28f --- /dev/null +++ b/tests/components/simplepush/test_config_flow.py @@ -0,0 +1,144 @@ +"""Test Simplepush config flow.""" +from unittest.mock import patch + +import pytest +from simplepush import UnknownError + +from homeassistant import config_entries, data_entry_flow +from homeassistant.components.simplepush.const import CONF_DEVICE_KEY, CONF_SALT, DOMAIN +from homeassistant.const import CONF_NAME, CONF_PASSWORD +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + +MOCK_CONFIG = { + CONF_DEVICE_KEY: "abc", + CONF_NAME: "simplepush", +} + + +@pytest.fixture(autouse=True) +def simplepush_setup_fixture(): + """Patch simplepush setup entry.""" + with patch( + "homeassistant.components.simplepush.async_setup_entry", return_value=True + ): + yield + + +@pytest.fixture(autouse=True) +def mock_api_request(): + """Patch simplepush api request.""" + with patch("homeassistant.components.simplepush.config_flow.send"), patch( + "homeassistant.components.simplepush.config_flow.send_encrypted" + ): + yield + + +async def test_flow_successful(hass: HomeAssistant) -> None: + """Test user initialized flow with minimum config.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_USER}, + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=MOCK_CONFIG, + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result["title"] == "simplepush" + assert result["data"] == MOCK_CONFIG + + +async def test_flow_with_password(hass: HomeAssistant) -> None: + """Test user initialized flow with password and salt.""" + mock_config_pass = {**MOCK_CONFIG, CONF_PASSWORD: "password", CONF_SALT: "salt"} + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_USER}, + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=mock_config_pass, + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result["title"] == "simplepush" + assert result["data"] == mock_config_pass + + +async def test_flow_user_device_key_already_configured(hass: HomeAssistant) -> None: + """Test user initialized flow with duplicate device key.""" + entry = MockConfigEntry( + domain=DOMAIN, + data=MOCK_CONFIG, + unique_id="abc", + ) + + entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_USER}, + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=MOCK_CONFIG, + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT + assert result["reason"] == "already_configured" + + +async def test_flow_user_name_already_configured(hass: HomeAssistant) -> None: + """Test user initialized flow with duplicate name.""" + entry = MockConfigEntry( + domain=DOMAIN, + data=MOCK_CONFIG, + unique_id="abc", + ) + + entry.add_to_hass(hass) + + new_entry = MOCK_CONFIG.copy() + new_entry[CONF_DEVICE_KEY] = "abc1" + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_USER}, + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=MOCK_CONFIG, + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT + assert result["reason"] == "already_configured" + + +async def test_error_on_connection_failure(hass: HomeAssistant) -> None: + """Test when connection to api fails.""" + with patch( + "homeassistant.components.simplepush.config_flow.send", + side_effect=UnknownError, + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_USER}, + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=MOCK_CONFIG, + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["errors"] == {"base": "cannot_connect"} + + +async def test_flow_import(hass: HomeAssistant) -> None: + """Test an import flow.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_IMPORT}, + data=MOCK_CONFIG, + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result["title"] == "simplepush" + assert result["data"] == MOCK_CONFIG