Add config flow to nfandroidtv (#51280)
Co-authored-by: Franck Nijhof <git@frenck.dev>
This commit is contained in:
parent
02a7a2464a
commit
462db1b4b2
14 changed files with 512 additions and 185 deletions
|
@ -693,6 +693,7 @@ omit =
|
||||||
homeassistant/components/neurio_energy/sensor.py
|
homeassistant/components/neurio_energy/sensor.py
|
||||||
homeassistant/components/nexia/climate.py
|
homeassistant/components/nexia/climate.py
|
||||||
homeassistant/components/nextcloud/*
|
homeassistant/components/nextcloud/*
|
||||||
|
homeassistant/components/nfandroidtv/__init__.py
|
||||||
homeassistant/components/nfandroidtv/notify.py
|
homeassistant/components/nfandroidtv/notify.py
|
||||||
homeassistant/components/niko_home_control/light.py
|
homeassistant/components/niko_home_control/light.py
|
||||||
homeassistant/components/nilu/air_quality.py
|
homeassistant/components/nilu/air_quality.py
|
||||||
|
|
|
@ -332,6 +332,7 @@ homeassistant/components/netdata/* @fabaff
|
||||||
homeassistant/components/nexia/* @bdraco
|
homeassistant/components/nexia/* @bdraco
|
||||||
homeassistant/components/nextbus/* @vividboarder
|
homeassistant/components/nextbus/* @vividboarder
|
||||||
homeassistant/components/nextcloud/* @meichthys
|
homeassistant/components/nextcloud/* @meichthys
|
||||||
|
homeassistant/components/nfandroidtv/* @tkdrob
|
||||||
homeassistant/components/nightscout/* @marciogranzotto
|
homeassistant/components/nightscout/* @marciogranzotto
|
||||||
homeassistant/components/nilu/* @hfurubotten
|
homeassistant/components/nilu/* @hfurubotten
|
||||||
homeassistant/components/nissan_leaf/* @filcole
|
homeassistant/components/nissan_leaf/* @filcole
|
||||||
|
|
|
@ -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
|
||||||
|
|
76
homeassistant/components/nfandroidtv/config_flow.py
Normal file
76
homeassistant/components/nfandroidtv/config_flow.py
Normal file
|
@ -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
|
28
homeassistant/components/nfandroidtv/const.py
Normal file
28
homeassistant/components/nfandroidtv/const.py
Normal file
|
@ -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"
|
|
@ -1,7 +1,9 @@
|
||||||
{
|
{
|
||||||
"domain": "nfandroidtv",
|
"domain": "nfandroidtv",
|
||||||
"name": "Notifications for Android TV / FireTV",
|
"name": "Notifications for Android TV / Fire TV",
|
||||||
"documentation": "https://www.home-assistant.io/integrations/nfandroidtv",
|
"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"
|
"iot_class": "local_push"
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,8 +1,7 @@
|
||||||
"""Notifications for Android TV notification service."""
|
"""Notifications for Android TV notification service."""
|
||||||
import base64
|
|
||||||
import io
|
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
|
from notifications_android_tv import Notifications
|
||||||
import requests
|
import requests
|
||||||
from requests.auth import HTTPBasicAuth, HTTPDigestAuth
|
from requests.auth import HTTPBasicAuth, HTTPDigestAuth
|
||||||
import voluptuous as vol
|
import voluptuous as vol
|
||||||
|
@ -14,115 +13,69 @@ from homeassistant.components.notify import (
|
||||||
PLATFORM_SCHEMA,
|
PLATFORM_SCHEMA,
|
||||||
BaseNotificationService,
|
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
|
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__)
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
CONF_DURATION = "duration"
|
# Deprecated in Home Assistant 2021.8
|
||||||
CONF_FONTSIZE = "fontsize"
|
PLATFORM_SCHEMA = cv.deprecated(
|
||||||
CONF_POSITION = "position"
|
vol.All(
|
||||||
CONF_TRANSPARENCY = "transparency"
|
PLATFORM_SCHEMA.extend(
|
||||||
CONF_COLOR = "color"
|
{
|
||||||
CONF_INTERRUPT = "interrupt"
|
vol.Required(CONF_HOST): cv.string,
|
||||||
|
vol.Optional(CONF_DURATION): vol.Coerce(int),
|
||||||
DEFAULT_DURATION = 5
|
vol.Optional(CONF_FONTSIZE): vol.In(Notifications.FONTSIZES.keys()),
|
||||||
DEFAULT_FONTSIZE = "medium"
|
vol.Optional(CONF_POSITION): vol.In(Notifications.POSITIONS.keys()),
|
||||||
DEFAULT_POSITION = "bottom-right"
|
vol.Optional(CONF_TRANSPARENCY): vol.In(
|
||||||
DEFAULT_TRANSPARENCY = "default"
|
Notifications.TRANSPARENCIES.keys()
|
||||||
DEFAULT_COLOR = "grey"
|
),
|
||||||
DEFAULT_INTERRUPT = False
|
vol.Optional(CONF_COLOR): vol.In(Notifications.BKG_COLORS.keys()),
|
||||||
DEFAULT_TIMEOUT = 5
|
vol.Optional(CONF_TIMEOUT): vol.Coerce(int),
|
||||||
DEFAULT_ICON = (
|
vol.Optional(CONF_INTERRUPT): cv.boolean,
|
||||||
"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()
|
|
||||||
),
|
),
|
||||||
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):
|
async def async_get_service(hass: HomeAssistant, config, discovery_info=None):
|
||||||
"""Get the Notifications for Android TV notification service."""
|
"""Get the NFAndroidTV notification service."""
|
||||||
remoteip = config.get(CONF_HOST)
|
if discovery_info is not None:
|
||||||
duration = config.get(CONF_DURATION)
|
notify = await hass.async_add_executor_job(
|
||||||
fontsize = config.get(CONF_FONTSIZE)
|
Notifications, discovery_info[CONF_HOST]
|
||||||
position = config.get(CONF_POSITION)
|
)
|
||||||
transparency = config.get(CONF_TRANSPARENCY)
|
return NFAndroidTVNotificationService(
|
||||||
color = config.get(CONF_COLOR)
|
notify,
|
||||||
interrupt = config.get(CONF_INTERRUPT)
|
hass.config.is_allowed_path,
|
||||||
timeout = config.get(CONF_TIMEOUT)
|
)
|
||||||
|
notify = await hass.async_add_executor_job(Notifications, config.get(CONF_HOST))
|
||||||
return NFAndroidTVNotificationService(
|
return NFAndroidTVNotificationService(
|
||||||
remoteip,
|
notify,
|
||||||
duration,
|
|
||||||
fontsize,
|
|
||||||
position,
|
|
||||||
transparency,
|
|
||||||
color,
|
|
||||||
interrupt,
|
|
||||||
timeout,
|
|
||||||
hass.config.is_allowed_path,
|
hass.config.is_allowed_path,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -132,116 +85,98 @@ class NFAndroidTVNotificationService(BaseNotificationService):
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
remoteip,
|
notify: Notifications,
|
||||||
duration,
|
|
||||||
fontsize,
|
|
||||||
position,
|
|
||||||
transparency,
|
|
||||||
color,
|
|
||||||
interrupt,
|
|
||||||
timeout,
|
|
||||||
is_allowed_path,
|
is_allowed_path,
|
||||||
):
|
):
|
||||||
"""Initialize the service."""
|
"""Initialize the service."""
|
||||||
self._target = f"http://{remoteip}:7676"
|
self.notify = notify
|
||||||
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.is_allowed_path = is_allowed_path
|
self.is_allowed_path = is_allowed_path
|
||||||
|
|
||||||
def send_message(self, message="", **kwargs):
|
def send_message(self, message="", **kwargs):
|
||||||
"""Send a message to a Android TV device."""
|
"""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)
|
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 data:
|
||||||
if ATTR_DURATION in data:
|
if ATTR_DURATION in data:
|
||||||
duration = data.get(ATTR_DURATION)
|
|
||||||
try:
|
try:
|
||||||
payload[ATTR_DURATION] = "%i" % int(duration)
|
duration = int(data.get(ATTR_DURATION))
|
||||||
except ValueError:
|
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:
|
if ATTR_FONTSIZE in data:
|
||||||
fontsize = data.get(ATTR_FONTSIZE)
|
if data.get(ATTR_FONTSIZE) in Notifications.FONTSIZES:
|
||||||
if fontsize in FONTSIZES:
|
fontsize = data.get(ATTR_FONTSIZE)
|
||||||
payload[ATTR_FONTSIZE] = "%i" % FONTSIZES.get(fontsize)
|
|
||||||
else:
|
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:
|
if ATTR_POSITION in data:
|
||||||
position = data.get(ATTR_POSITION)
|
if data.get(ATTR_POSITION) in Notifications.POSITIONS:
|
||||||
if position in POSITIONS:
|
position = data.get(ATTR_POSITION)
|
||||||
payload[ATTR_POSITION] = "%i" % POSITIONS.get(position)
|
|
||||||
else:
|
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:
|
if ATTR_TRANSPARENCY in data:
|
||||||
transparency = data.get(ATTR_TRANSPARENCY)
|
if data.get(ATTR_TRANSPARENCY) in Notifications.TRANSPARENCIES:
|
||||||
if transparency in TRANSPARENCIES:
|
transparency = data.get(ATTR_TRANSPARENCY)
|
||||||
payload[ATTR_TRANSPARENCY] = "%i" % TRANSPARENCIES.get(transparency)
|
|
||||||
else:
|
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:
|
if ATTR_COLOR in data:
|
||||||
color = data.get(ATTR_COLOR)
|
if data.get(ATTR_COLOR) in Notifications.BKG_COLORS:
|
||||||
if color in COLORS:
|
bkgcolor = data.get(ATTR_COLOR)
|
||||||
payload[ATTR_BKGCOLOR] = "%s" % COLORS.get(color)
|
|
||||||
else:
|
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:
|
if ATTR_INTERRUPT in data:
|
||||||
interrupt = data.get(ATTR_INTERRUPT)
|
|
||||||
try:
|
try:
|
||||||
payload[ATTR_INTERRUPT] = "%i" % cv.boolean(interrupt)
|
interrupt = cv.boolean(data.get(ATTR_INTERRUPT))
|
||||||
except vol.Invalid:
|
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
|
filedata = data.get(ATTR_FILE) if data else None
|
||||||
if filedata is not None:
|
if filedata is not None:
|
||||||
# Load from file or URL
|
if ATTR_ICON in filedata:
|
||||||
file_as_bytes = self.load_file(
|
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),
|
url=filedata.get(ATTR_FILE_URL),
|
||||||
local_path=filedata.get(ATTR_FILE_PATH),
|
local_path=filedata.get(ATTR_FILE_PATH),
|
||||||
username=filedata.get(ATTR_FILE_USERNAME),
|
username=filedata.get(ATTR_FILE_USERNAME),
|
||||||
password=filedata.get(ATTR_FILE_PASSWORD),
|
password=filedata.get(ATTR_FILE_PASSWORD),
|
||||||
auth=filedata.get(ATTR_FILE_AUTH),
|
auth=filedata.get(ATTR_FILE_AUTH),
|
||||||
)
|
)
|
||||||
if file_as_bytes:
|
self.notify.send(
|
||||||
payload[ATTR_IMAGE] = (
|
message,
|
||||||
"image",
|
title=title,
|
||||||
file_as_bytes,
|
duration=duration,
|
||||||
"application/octet-stream",
|
fontsize=fontsize,
|
||||||
{"Expires": "0"},
|
position=position,
|
||||||
)
|
bkgcolor=bkgcolor,
|
||||||
|
transparency=transparency,
|
||||||
try:
|
interrupt=interrupt,
|
||||||
_LOGGER.debug("Payload: %s", str(payload))
|
icon=icon,
|
||||||
response = requests.post(self._target, files=payload, timeout=self._timeout)
|
image_file=image_file,
|
||||||
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))
|
|
||||||
|
|
||||||
def load_file(
|
def load_file(
|
||||||
self, url=None, local_path=None, username=None, password=None, auth=None
|
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:
|
if local_path is not None:
|
||||||
# Check whether path is whitelisted in configuration.yaml
|
# Check whether path is whitelisted in configuration.yaml
|
||||||
if self.is_allowed_path(local_path):
|
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)
|
_LOGGER.warning("'%s' is not secure to load data from!", local_path)
|
||||||
else:
|
else:
|
||||||
_LOGGER.warning("Neither URL nor local path found in params!")
|
_LOGGER.warning("Neither URL nor local path found in params!")
|
||||||
|
|
21
homeassistant/components/nfandroidtv/strings.json
Normal file
21
homeassistant/components/nfandroidtv/strings.json
Normal file
|
@ -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%]"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
21
homeassistant/components/nfandroidtv/translations/en.json
Normal file
21
homeassistant/components/nfandroidtv/translations/en.json
Normal file
|
@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -178,6 +178,7 @@ FLOWS = [
|
||||||
"nest",
|
"nest",
|
||||||
"netatmo",
|
"netatmo",
|
||||||
"nexia",
|
"nexia",
|
||||||
|
"nfandroidtv",
|
||||||
"nightscout",
|
"nightscout",
|
||||||
"notion",
|
"notion",
|
||||||
"nuheat",
|
"nuheat",
|
||||||
|
|
|
@ -1032,6 +1032,9 @@ niluclient==0.1.2
|
||||||
# homeassistant.components.noaa_tides
|
# homeassistant.components.noaa_tides
|
||||||
noaa-coops==0.1.8
|
noaa-coops==0.1.8
|
||||||
|
|
||||||
|
# homeassistant.components.nfandroidtv
|
||||||
|
notifications-android-tv==0.1.2
|
||||||
|
|
||||||
# homeassistant.components.notify_events
|
# homeassistant.components.notify_events
|
||||||
notify-events==1.0.4
|
notify-events==1.0.4
|
||||||
|
|
||||||
|
|
|
@ -577,6 +577,9 @@ nettigo-air-monitor==1.0.0
|
||||||
# homeassistant.components.nexia
|
# homeassistant.components.nexia
|
||||||
nexia==0.9.10
|
nexia==0.9.10
|
||||||
|
|
||||||
|
# homeassistant.components.nfandroidtv
|
||||||
|
notifications-android-tv==0.1.2
|
||||||
|
|
||||||
# homeassistant.components.notify_events
|
# homeassistant.components.notify_events
|
||||||
notify-events==1.0.4
|
notify-events==1.0.4
|
||||||
|
|
||||||
|
|
31
tests/components/nfandroidtv/__init__.py
Normal file
31
tests/components/nfandroidtv/__init__.py
Normal file
|
@ -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,
|
||||||
|
)
|
135
tests/components/nfandroidtv/test_config_flow.py
Normal file
135
tests/components/nfandroidtv/test_config_flow.py
Normal file
|
@ -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}"}
|
Loading…
Add table
Reference in a new issue