diff --git a/.coveragerc b/.coveragerc index 49c2cb98424..83212125cb7 100644 --- a/.coveragerc +++ b/.coveragerc @@ -693,6 +693,7 @@ omit = homeassistant/components/neurio_energy/sensor.py homeassistant/components/nexia/climate.py homeassistant/components/nextcloud/* + homeassistant/components/nfandroidtv/__init__.py homeassistant/components/nfandroidtv/notify.py homeassistant/components/niko_home_control/light.py homeassistant/components/nilu/air_quality.py diff --git a/CODEOWNERS b/CODEOWNERS index e5a9bcd5823..5fa884fda15 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -332,6 +332,7 @@ homeassistant/components/netdata/* @fabaff homeassistant/components/nexia/* @bdraco homeassistant/components/nextbus/* @vividboarder homeassistant/components/nextcloud/* @meichthys +homeassistant/components/nfandroidtv/* @tkdrob homeassistant/components/nightscout/* @marciogranzotto homeassistant/components/nilu/* @hfurubotten homeassistant/components/nissan_leaf/* @filcole diff --git a/homeassistant/components/nfandroidtv/__init__.py b/homeassistant/components/nfandroidtv/__init__.py index 9965265e00d..90a76c1c747 100644 --- a/homeassistant/components/nfandroidtv/__init__.py +++ b/homeassistant/components/nfandroidtv/__init__.py @@ -1 +1,69 @@ -"""The nfandroidtv component.""" +"""The NFAndroidTV integration.""" +import logging + +from notifications_android_tv.notifications import ConnectError, Notifications + +from homeassistant.components.notify import DOMAIN as NOTIFY +from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry +from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PLATFORM +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers import discovery + +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + +PLATFORMS = [NOTIFY] + + +async def async_setup(hass: HomeAssistant, config): + """Set up the NFAndroidTV component.""" + hass.data.setdefault(DOMAIN, {}) + # Iterate all entries for notify to only get nfandroidtv + if NOTIFY in config: + for entry in config[NOTIFY]: + if entry[CONF_PLATFORM] == DOMAIN: + hass.async_create_task( + hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_IMPORT}, data=entry + ) + ) + + return True + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up NFAndroidTV from a config entry.""" + host = entry.data[CONF_HOST] + name = entry.data[CONF_NAME] + + try: + await hass.async_add_executor_job(Notifications, host) + except ConnectError as ex: + _LOGGER.warning("Failed to connect: %s", ex) + raise ConfigEntryNotReady from ex + + hass.data.setdefault(DOMAIN, {}) + hass.data[DOMAIN][entry.entry_id] = { + CONF_HOST: host, + CONF_NAME: name, + } + + hass.async_create_task( + discovery.async_load_platform( + hass, NOTIFY, DOMAIN, hass.data[DOMAIN][entry.entry_id], hass.data[DOMAIN] + ) + ) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload a config entry.""" + unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + + if unload_ok: + hass.data[DOMAIN].pop(entry.entry_id) + + return unload_ok diff --git a/homeassistant/components/nfandroidtv/config_flow.py b/homeassistant/components/nfandroidtv/config_flow.py new file mode 100644 index 00000000000..0f7cffcff4b --- /dev/null +++ b/homeassistant/components/nfandroidtv/config_flow.py @@ -0,0 +1,76 @@ +"""Config flow for NFAndroidTV integration.""" +from __future__ import annotations + +import logging + +from notifications_android_tv.notifications import ConnectError, Notifications +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.const import CONF_HOST, CONF_NAME +from homeassistant.data_entry_flow import FlowResult + +from .const import DEFAULT_NAME, DOMAIN + +_LOGGER = logging.getLogger(__name__) + + +class NFAndroidTVFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a config flow for NFAndroidTV.""" + + async def async_step_user(self, user_input=None) -> FlowResult: + """Handle a flow initiated by the user.""" + errors = {} + + if user_input is not None: + host = user_input[CONF_HOST] + name = user_input[CONF_NAME] + + await self.async_set_unique_id(host) + self._abort_if_unique_id_configured() + error = await self._async_try_connect(host) + if error is None: + return self.async_create_entry( + title=name, + data={CONF_HOST: host, CONF_NAME: name}, + ) + errors["base"] = error + + user_input = user_input or {} + return self.async_show_form( + step_id="user", + data_schema=vol.Schema( + { + vol.Required(CONF_HOST, default=user_input.get(CONF_HOST)): str, + vol.Required( + CONF_NAME, default=user_input.get(CONF_NAME, DEFAULT_NAME) + ): str, + } + ), + errors=errors, + ) + + async def async_step_import(self, import_config): + """Import a config entry from configuration.yaml.""" + for entry in self._async_current_entries(): + if entry.data[CONF_HOST] == import_config[CONF_HOST]: + _LOGGER.warning( + "Already configured. This yaml configuration has already been imported. Please remove it" + ) + return self.async_abort(reason="already_configured") + if CONF_NAME not in import_config: + import_config[CONF_NAME] = f"{DEFAULT_NAME} {import_config[CONF_HOST]}" + + return await self.async_step_user(import_config) + + async def _async_try_connect(self, host): + """Try connecting to Android TV / Fire TV.""" + try: + await self.hass.async_add_executor_job(Notifications, host) + except ConnectError: + _LOGGER.error("Error connecting to device at %s", host) + return "cannot_connect" + except Exception: # pylint: disable=broad-except + _LOGGER.exception("Unexpected exception") + return "unknown" + return diff --git a/homeassistant/components/nfandroidtv/const.py b/homeassistant/components/nfandroidtv/const.py new file mode 100644 index 00000000000..332c1754771 --- /dev/null +++ b/homeassistant/components/nfandroidtv/const.py @@ -0,0 +1,28 @@ +"""Constants for the NFAndroidTV integration.""" +DOMAIN: str = "nfandroidtv" +CONF_DURATION = "duration" +CONF_FONTSIZE = "fontsize" +CONF_POSITION = "position" +CONF_TRANSPARENCY = "transparency" +CONF_COLOR = "color" +CONF_INTERRUPT = "interrupt" + +DEFAULT_NAME = "Android TV / Fire TV" +DEFAULT_TIMEOUT = 5 + +ATTR_DURATION = "duration" +ATTR_FONTSIZE = "fontsize" +ATTR_POSITION = "position" +ATTR_TRANSPARENCY = "transparency" +ATTR_COLOR = "color" +ATTR_BKGCOLOR = "bkgcolor" +ATTR_INTERRUPT = "interrupt" +ATTR_FILE = "file" +# Attributes contained in file +ATTR_FILE_URL = "url" +ATTR_FILE_PATH = "path" +ATTR_FILE_USERNAME = "username" +ATTR_FILE_PASSWORD = "password" +ATTR_FILE_AUTH = "auth" +# Any other value or absence of 'auth' lead to basic authentication being used +ATTR_FILE_AUTH_DIGEST = "digest" diff --git a/homeassistant/components/nfandroidtv/manifest.json b/homeassistant/components/nfandroidtv/manifest.json index 6f29d4d410e..5516f144fd4 100644 --- a/homeassistant/components/nfandroidtv/manifest.json +++ b/homeassistant/components/nfandroidtv/manifest.json @@ -1,7 +1,9 @@ { "domain": "nfandroidtv", - "name": "Notifications for Android TV / FireTV", + "name": "Notifications for Android TV / Fire TV", "documentation": "https://www.home-assistant.io/integrations/nfandroidtv", - "codeowners": [], + "requirements": ["notifications-android-tv==0.1.2"], + "codeowners": ["@tkdrob"], + "config_flow": true, "iot_class": "local_push" } diff --git a/homeassistant/components/nfandroidtv/notify.py b/homeassistant/components/nfandroidtv/notify.py index ad2f3fb3706..c2a42760aec 100644 --- a/homeassistant/components/nfandroidtv/notify.py +++ b/homeassistant/components/nfandroidtv/notify.py @@ -1,8 +1,7 @@ """Notifications for Android TV notification service.""" -import base64 -import io import logging +from notifications_android_tv import Notifications import requests from requests.auth import HTTPBasicAuth, HTTPDigestAuth import voluptuous as vol @@ -14,115 +13,69 @@ from homeassistant.components.notify import ( PLATFORM_SCHEMA, BaseNotificationService, ) -from homeassistant.const import CONF_HOST, CONF_TIMEOUT, HTTP_OK, PERCENTAGE +from homeassistant.const import ATTR_ICON, CONF_HOST, CONF_TIMEOUT +from homeassistant.core import HomeAssistant import homeassistant.helpers.config_validation as cv +from .const import ( + ATTR_COLOR, + ATTR_DURATION, + ATTR_FILE, + ATTR_FILE_AUTH, + ATTR_FILE_AUTH_DIGEST, + ATTR_FILE_PASSWORD, + ATTR_FILE_PATH, + ATTR_FILE_URL, + ATTR_FILE_USERNAME, + ATTR_FONTSIZE, + ATTR_INTERRUPT, + ATTR_POSITION, + ATTR_TRANSPARENCY, + CONF_COLOR, + CONF_DURATION, + CONF_FONTSIZE, + CONF_INTERRUPT, + CONF_POSITION, + CONF_TRANSPARENCY, + DEFAULT_TIMEOUT, +) + _LOGGER = logging.getLogger(__name__) -CONF_DURATION = "duration" -CONF_FONTSIZE = "fontsize" -CONF_POSITION = "position" -CONF_TRANSPARENCY = "transparency" -CONF_COLOR = "color" -CONF_INTERRUPT = "interrupt" - -DEFAULT_DURATION = 5 -DEFAULT_FONTSIZE = "medium" -DEFAULT_POSITION = "bottom-right" -DEFAULT_TRANSPARENCY = "default" -DEFAULT_COLOR = "grey" -DEFAULT_INTERRUPT = False -DEFAULT_TIMEOUT = 5 -DEFAULT_ICON = ( - "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR4nGP6zwAAAgcBApo" - "cMXEAAAAASUVORK5CYII=" -) - -ATTR_DURATION = "duration" -ATTR_FONTSIZE = "fontsize" -ATTR_POSITION = "position" -ATTR_TRANSPARENCY = "transparency" -ATTR_COLOR = "color" -ATTR_BKGCOLOR = "bkgcolor" -ATTR_INTERRUPT = "interrupt" -ATTR_IMAGE = "filename2" -ATTR_FILE = "file" -# Attributes contained in file -ATTR_FILE_URL = "url" -ATTR_FILE_PATH = "path" -ATTR_FILE_USERNAME = "username" -ATTR_FILE_PASSWORD = "password" -ATTR_FILE_AUTH = "auth" -# Any other value or absence of 'auth' lead to basic authentication being used -ATTR_FILE_AUTH_DIGEST = "digest" - -FONTSIZES = {"small": 1, "medium": 0, "large": 2, "max": 3} - -POSITIONS = { - "bottom-right": 0, - "bottom-left": 1, - "top-right": 2, - "top-left": 3, - "center": 4, -} - -TRANSPARENCIES = { - "default": 0, - f"0{PERCENTAGE}": 1, - f"25{PERCENTAGE}": 2, - f"50{PERCENTAGE}": 3, - f"75{PERCENTAGE}": 4, - f"100{PERCENTAGE}": 5, -} - -COLORS = { - "grey": "#607d8b", - "black": "#000000", - "indigo": "#303F9F", - "green": "#4CAF50", - "red": "#F44336", - "cyan": "#00BCD4", - "teal": "#009688", - "amber": "#FFC107", - "pink": "#E91E63", -} - -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( - { - vol.Required(CONF_HOST): cv.string, - vol.Optional(CONF_DURATION, default=DEFAULT_DURATION): vol.Coerce(int), - vol.Optional(CONF_FONTSIZE, default=DEFAULT_FONTSIZE): vol.In(FONTSIZES.keys()), - vol.Optional(CONF_POSITION, default=DEFAULT_POSITION): vol.In(POSITIONS.keys()), - vol.Optional(CONF_TRANSPARENCY, default=DEFAULT_TRANSPARENCY): vol.In( - TRANSPARENCIES.keys() +# Deprecated in Home Assistant 2021.8 +PLATFORM_SCHEMA = cv.deprecated( + vol.All( + PLATFORM_SCHEMA.extend( + { + vol.Required(CONF_HOST): cv.string, + vol.Optional(CONF_DURATION): vol.Coerce(int), + vol.Optional(CONF_FONTSIZE): vol.In(Notifications.FONTSIZES.keys()), + vol.Optional(CONF_POSITION): vol.In(Notifications.POSITIONS.keys()), + vol.Optional(CONF_TRANSPARENCY): vol.In( + Notifications.TRANSPARENCIES.keys() + ), + vol.Optional(CONF_COLOR): vol.In(Notifications.BKG_COLORS.keys()), + vol.Optional(CONF_TIMEOUT): vol.Coerce(int), + vol.Optional(CONF_INTERRUPT): cv.boolean, + } ), - vol.Optional(CONF_COLOR, default=DEFAULT_COLOR): vol.In(COLORS.keys()), - vol.Optional(CONF_TIMEOUT, default=DEFAULT_TIMEOUT): vol.Coerce(int), - vol.Optional(CONF_INTERRUPT, default=DEFAULT_INTERRUPT): cv.boolean, - } + ) ) -def get_service(hass, config, discovery_info=None): - """Get the Notifications for Android TV notification service.""" - remoteip = config.get(CONF_HOST) - duration = config.get(CONF_DURATION) - fontsize = config.get(CONF_FONTSIZE) - position = config.get(CONF_POSITION) - transparency = config.get(CONF_TRANSPARENCY) - color = config.get(CONF_COLOR) - interrupt = config.get(CONF_INTERRUPT) - timeout = config.get(CONF_TIMEOUT) - +async def async_get_service(hass: HomeAssistant, config, discovery_info=None): + """Get the NFAndroidTV notification service.""" + if discovery_info is not None: + notify = await hass.async_add_executor_job( + Notifications, discovery_info[CONF_HOST] + ) + return NFAndroidTVNotificationService( + notify, + hass.config.is_allowed_path, + ) + notify = await hass.async_add_executor_job(Notifications, config.get(CONF_HOST)) return NFAndroidTVNotificationService( - remoteip, - duration, - fontsize, - position, - transparency, - color, - interrupt, - timeout, + notify, hass.config.is_allowed_path, ) @@ -132,116 +85,98 @@ class NFAndroidTVNotificationService(BaseNotificationService): def __init__( self, - remoteip, - duration, - fontsize, - position, - transparency, - color, - interrupt, - timeout, + notify: Notifications, is_allowed_path, ): """Initialize the service.""" - self._target = f"http://{remoteip}:7676" - self._default_duration = duration - self._default_fontsize = fontsize - self._default_position = position - self._default_transparency = transparency - self._default_color = color - self._default_interrupt = interrupt - self._timeout = timeout - self._icon_file = io.BytesIO(base64.b64decode(DEFAULT_ICON)) + self.notify = notify self.is_allowed_path = is_allowed_path def send_message(self, message="", **kwargs): """Send a message to a Android TV device.""" - _LOGGER.debug("Sending notification to: %s", self._target) - - payload = { - "filename": ( - "icon.png", - self._icon_file, - "application/octet-stream", - {"Expires": "0"}, - ), - "type": "0", - "title": kwargs.get(ATTR_TITLE, ATTR_TITLE_DEFAULT), - "msg": message, - "duration": "%i" % self._default_duration, - "fontsize": "%i" % FONTSIZES.get(self._default_fontsize), - "position": "%i" % POSITIONS.get(self._default_position), - "bkgcolor": "%s" % COLORS.get(self._default_color), - "transparency": "%i" % TRANSPARENCIES.get(self._default_transparency), - "offset": "0", - "app": ATTR_TITLE_DEFAULT, - "force": "true", - "interrupt": "%i" % self._default_interrupt, - } - data = kwargs.get(ATTR_DATA) + title = kwargs.get(ATTR_TITLE, ATTR_TITLE_DEFAULT) + duration = None + fontsize = None + position = None + transparency = None + bkgcolor = None + interrupt = None + icon = None + image_file = None if data: if ATTR_DURATION in data: - duration = data.get(ATTR_DURATION) try: - payload[ATTR_DURATION] = "%i" % int(duration) + duration = int(data.get(ATTR_DURATION)) except ValueError: - _LOGGER.warning("Invalid duration-value: %s", str(duration)) + _LOGGER.warning( + "Invalid duration-value: %s", str(data.get(ATTR_DURATION)) + ) if ATTR_FONTSIZE in data: - fontsize = data.get(ATTR_FONTSIZE) - if fontsize in FONTSIZES: - payload[ATTR_FONTSIZE] = "%i" % FONTSIZES.get(fontsize) + if data.get(ATTR_FONTSIZE) in Notifications.FONTSIZES: + fontsize = data.get(ATTR_FONTSIZE) else: - _LOGGER.warning("Invalid fontsize-value: %s", str(fontsize)) + _LOGGER.warning( + "Invalid fontsize-value: %s", str(data.get(ATTR_FONTSIZE)) + ) if ATTR_POSITION in data: - position = data.get(ATTR_POSITION) - if position in POSITIONS: - payload[ATTR_POSITION] = "%i" % POSITIONS.get(position) + if data.get(ATTR_POSITION) in Notifications.POSITIONS: + position = data.get(ATTR_POSITION) else: - _LOGGER.warning("Invalid position-value: %s", str(position)) + _LOGGER.warning( + "Invalid position-value: %s", str(data.get(ATTR_POSITION)) + ) if ATTR_TRANSPARENCY in data: - transparency = data.get(ATTR_TRANSPARENCY) - if transparency in TRANSPARENCIES: - payload[ATTR_TRANSPARENCY] = "%i" % TRANSPARENCIES.get(transparency) + if data.get(ATTR_TRANSPARENCY) in Notifications.TRANSPARENCIES: + transparency = data.get(ATTR_TRANSPARENCY) else: - _LOGGER.warning("Invalid transparency-value: %s", str(transparency)) + _LOGGER.warning( + "Invalid transparency-value: %s", + str(data.get(ATTR_TRANSPARENCY)), + ) if ATTR_COLOR in data: - color = data.get(ATTR_COLOR) - if color in COLORS: - payload[ATTR_BKGCOLOR] = "%s" % COLORS.get(color) + if data.get(ATTR_COLOR) in Notifications.BKG_COLORS: + bkgcolor = data.get(ATTR_COLOR) else: - _LOGGER.warning("Invalid color-value: %s", str(color)) + _LOGGER.warning( + "Invalid color-value: %s", str(data.get(ATTR_COLOR)) + ) if ATTR_INTERRUPT in data: - interrupt = data.get(ATTR_INTERRUPT) try: - payload[ATTR_INTERRUPT] = "%i" % cv.boolean(interrupt) + interrupt = cv.boolean(data.get(ATTR_INTERRUPT)) except vol.Invalid: - _LOGGER.warning("Invalid interrupt-value: %s", str(interrupt)) + _LOGGER.warning( + "Invalid interrupt-value: %s", str(data.get(ATTR_INTERRUPT)) + ) filedata = data.get(ATTR_FILE) if data else None if filedata is not None: - # Load from file or URL - file_as_bytes = self.load_file( + if ATTR_ICON in filedata: + icon = self.load_file( + url=filedata.get(ATTR_ICON), + local_path=filedata.get(ATTR_FILE_PATH), + username=filedata.get(ATTR_FILE_USERNAME), + password=filedata.get(ATTR_FILE_PASSWORD), + auth=filedata.get(ATTR_FILE_AUTH), + ) + image_file = self.load_file( url=filedata.get(ATTR_FILE_URL), local_path=filedata.get(ATTR_FILE_PATH), username=filedata.get(ATTR_FILE_USERNAME), password=filedata.get(ATTR_FILE_PASSWORD), auth=filedata.get(ATTR_FILE_AUTH), ) - if file_as_bytes: - payload[ATTR_IMAGE] = ( - "image", - file_as_bytes, - "application/octet-stream", - {"Expires": "0"}, - ) - - try: - _LOGGER.debug("Payload: %s", str(payload)) - response = requests.post(self._target, files=payload, timeout=self._timeout) - if response.status_code != HTTP_OK: - _LOGGER.error("Error sending message: %s", str(response)) - except requests.exceptions.ConnectionError as err: - _LOGGER.error("Error communicating with %s: %s", self._target, str(err)) + self.notify.send( + message, + title=title, + duration=duration, + fontsize=fontsize, + position=position, + bkgcolor=bkgcolor, + transparency=transparency, + interrupt=interrupt, + icon=icon, + image_file=image_file, + ) def load_file( self, url=None, local_path=None, username=None, password=None, auth=None @@ -266,7 +201,8 @@ class NFAndroidTVNotificationService(BaseNotificationService): if local_path is not None: # Check whether path is whitelisted in configuration.yaml if self.is_allowed_path(local_path): - return open(local_path, "rb") # pylint: disable=consider-using-with + with open(local_path, "rb") as path_handle: + return path_handle _LOGGER.warning("'%s' is not secure to load data from!", local_path) else: _LOGGER.warning("Neither URL nor local path found in params!") diff --git a/homeassistant/components/nfandroidtv/strings.json b/homeassistant/components/nfandroidtv/strings.json new file mode 100644 index 00000000000..5940f86a406 --- /dev/null +++ b/homeassistant/components/nfandroidtv/strings.json @@ -0,0 +1,21 @@ +{ + "config": { + "step": { + "user": { + "title": "Notifications for Android TV / Fire TV", + "description": "This integration requires the Notifications for Android TV app.\n\nFor Android TV: https://play.google.com/store/apps/details?id=de.cyberdream.androidtv.notifications.google\nFor Fire TV: https://www.amazon.com/Christian-Fees-Notifications-for-Fire/dp/B00OESCXEK\n\nYou should set up either DHCP reservation on your router (refer to your router's user manual) or a static IP address on the device. If not, the device will eventually become unavailable.", + "data": { + "host": "[%key:common::config_flow::data::host%]", + "name": "[%key:common::config_flow::data::name%]" + } + } + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" + } + } +} diff --git a/homeassistant/components/nfandroidtv/translations/en.json b/homeassistant/components/nfandroidtv/translations/en.json new file mode 100644 index 00000000000..22d014c1ffa --- /dev/null +++ b/homeassistant/components/nfandroidtv/translations/en.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "already_configured": "Device is already configured" + }, + "error": { + "cannot_connect": "Failed to connect", + "unknown": "Unexpected error" + }, + "step": { + "user": { + "data": { + "host": "Host", + "name": "Name" + }, + "description": "This integration requires the Notifications for Android TV app.\n\nFor Android TV: https://play.google.com/store/apps/details?id=de.cyberdream.androidtv.notifications.google\nFor Fire TV: https://www.amazon.com/Christian-Fees-Notifications-for-Fire/dp/B00OESCXEK\n\nYou should set up either DHCP reservation on your router (refer to your router's user manual) or a static IP address on the device. If not, the device will eventually become unavailable.", + "title": "Notifications for Android TV / Fire TV" + } + } + } +} diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 1321c01b27d..943ca9cda74 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -178,6 +178,7 @@ FLOWS = [ "nest", "netatmo", "nexia", + "nfandroidtv", "nightscout", "notion", "nuheat", diff --git a/requirements_all.txt b/requirements_all.txt index 0559e927aec..bb119c6a2c9 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1032,6 +1032,9 @@ niluclient==0.1.2 # homeassistant.components.noaa_tides noaa-coops==0.1.8 +# homeassistant.components.nfandroidtv +notifications-android-tv==0.1.2 + # homeassistant.components.notify_events notify-events==1.0.4 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 80f25c59e31..22a017442eb 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -577,6 +577,9 @@ nettigo-air-monitor==1.0.0 # homeassistant.components.nexia nexia==0.9.10 +# homeassistant.components.nfandroidtv +notifications-android-tv==0.1.2 + # homeassistant.components.notify_events notify-events==1.0.4 diff --git a/tests/components/nfandroidtv/__init__.py b/tests/components/nfandroidtv/__init__.py new file mode 100644 index 00000000000..056e2b2bc71 --- /dev/null +++ b/tests/components/nfandroidtv/__init__.py @@ -0,0 +1,31 @@ +"""Tests for the NFAndroidTV integration.""" + +from unittest.mock import AsyncMock, patch + +from homeassistant.const import CONF_HOST, CONF_NAME + +HOST = "1.2.3.4" +NAME = "Android TV / Fire TV" + +CONF_DATA = { + CONF_HOST: HOST, + CONF_NAME: NAME, +} + +CONF_CONFIG_FLOW = { + CONF_HOST: HOST, + CONF_NAME: NAME, +} + + +async def _create_mocked_tv(raise_exception=False): + mocked_tv = AsyncMock() + mocked_tv.get_state = AsyncMock() + return mocked_tv + + +def _patch_config_flow_tv(mocked_tv): + return patch( + "homeassistant.components.nfandroidtv.config_flow.Notifications", + return_value=mocked_tv, + ) diff --git a/tests/components/nfandroidtv/test_config_flow.py b/tests/components/nfandroidtv/test_config_flow.py new file mode 100644 index 00000000000..b16b053c70f --- /dev/null +++ b/tests/components/nfandroidtv/test_config_flow.py @@ -0,0 +1,135 @@ +"""Test NFAndroidTV config flow.""" +from unittest.mock import patch + +from notifications_android_tv.notifications import ConnectError + +from homeassistant import config_entries, data_entry_flow +from homeassistant.components.nfandroidtv.const import DEFAULT_NAME, DOMAIN +from homeassistant.const import CONF_HOST, CONF_NAME + +from . import ( + CONF_CONFIG_FLOW, + CONF_DATA, + HOST, + NAME, + _create_mocked_tv, + _patch_config_flow_tv, +) + +from tests.common import MockConfigEntry + + +def _patch_setup(): + return patch( + "homeassistant.components.nfandroidtv.async_setup_entry", + return_value=True, + ) + + +async def test_flow_user(hass): + """Test user initialized flow.""" + mocked_tv = await _create_mocked_tv() + with _patch_config_flow_tv(mocked_tv), _patch_setup(): + 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=CONF_CONFIG_FLOW, + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result["title"] == NAME + assert result["data"] == CONF_DATA + + +async def test_flow_user_already_configured(hass): + """Test user initialized flow with duplicate server.""" + entry = MockConfigEntry( + domain=DOMAIN, + data=CONF_CONFIG_FLOW, + unique_id=HOST, + ) + + entry.add_to_hass(hass) + + mocked_tv = await _create_mocked_tv() + with _patch_config_flow_tv(mocked_tv), _patch_setup(): + 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=CONF_CONFIG_FLOW, + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT + assert result["reason"] == "already_configured" + + +async def test_flow_user_cannot_connect(hass): + """Test user initialized flow with unreachable server.""" + mocked_tv = await _create_mocked_tv(True) + with _patch_config_flow_tv(mocked_tv) as tvmock: + tvmock.side_effect = ConnectError + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_USER}, + data=CONF_CONFIG_FLOW, + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "user" + assert result["errors"] == {"base": "cannot_connect"} + + +async def test_flow_user_unknown_error(hass): + """Test user initialized flow with unreachable server.""" + mocked_tv = await _create_mocked_tv(True) + with _patch_config_flow_tv(mocked_tv) as tvmock: + tvmock.side_effect = Exception + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_USER}, + data=CONF_CONFIG_FLOW, + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "user" + assert result["errors"] == {"base": "unknown"} + + +async def test_flow_import(hass): + """Test an import flow.""" + mocked_tv = await _create_mocked_tv(True) + with _patch_config_flow_tv(mocked_tv), _patch_setup(): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_IMPORT}, + data=CONF_CONFIG_FLOW, + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result["data"] == CONF_DATA + + with _patch_config_flow_tv(mocked_tv), _patch_setup(): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_IMPORT}, + data=CONF_CONFIG_FLOW, + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT + assert result["reason"] == "already_configured" + + +async def test_flow_import_missing_optional(hass): + """Test an import flow with missing options.""" + mocked_tv = await _create_mocked_tv(True) + with _patch_config_flow_tv(mocked_tv), _patch_setup(): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_IMPORT}, + data={CONF_HOST: HOST}, + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result["data"] == {CONF_HOST: HOST, CONF_NAME: f"{DEFAULT_NAME} {HOST}"}