Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add Gotify component #53050

Closed
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
1c0b66a
Add Gotify component. Update CODEOWNERS and .coveragerc for new compo…
benjmarshall Jul 14, 2021
4581a82
Update homeassistant/components/gotify/manifest.json
benjmarshall Jul 30, 2021
fee256f
Update old function name
benjmarshall Jul 30, 2021
a4bc041
Use external library for service communication
benjmarshall Jul 30, 2021
4532ed1
Migrate component to config flow.
benjmarshall Aug 1, 2021
135ff81
Simplify ConfigFlow
milanmeu Aug 1, 2021
283d95e
Merge pull request #5 from milanmeu/patch-9
benjmarshall Aug 2, 2021
5c610f3
Update tests to be compatible with simplified config flow
benjmarshall Aug 2, 2021
e60f4d0
Merge pull request #6 from benjmarshall/gotify_config_flow
benjmarshall Aug 2, 2021
c53b8a5
Remove unused strings.
benjmarshall Aug 2, 2021
537b537
Fix and update translation strings
benjmarshall Aug 2, 2021
99825bc
Fix calling discovery for Notification platform. Fix creation of spec…
benjmarshall Aug 3, 2021
02654ad
Update interaction with gotify lib to use new class support
benjmarshall Aug 4, 2021
f4bd2b5
Fix default form input.
benjmarshall Aug 4, 2021
d3e02cb
Update tests to match updated config flow
benjmarshall Aug 4, 2021
3ee71d2
Change class to cloud_polling
benjmarshall Aug 4, 2021
ca2cf85
Add unload function to remove notify service when config entry is rem…
benjmarshall Aug 4, 2021
632f84e
Update setup and unload functions with suggested changes from review
benjmarshall Aug 4, 2021
29ef3f8
Use entry_id as ID to store config against. Re-work initial configura…
benjmarshall Jan 17, 2022
eaa96e2
Remove print statement.
benjmarshall Jan 17, 2022
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
1 change: 1 addition & 0 deletions .coveragerc
Expand Up @@ -380,6 +380,7 @@ omit =
homeassistant/components/google_travel_time/__init__.py
homeassistant/components/google_travel_time/helpers.py
homeassistant/components/google_travel_time/sensor.py
homeassistant/components/gotify/notify.py
homeassistant/components/gpmdp/media_player.py
homeassistant/components/gpsd/sensor.py
homeassistant/components/greeneye_monitor/*
Expand Down
1 change: 1 addition & 0 deletions CODEOWNERS
Validating CODEOWNERS rules …
Expand Up @@ -190,6 +190,7 @@ homeassistant/components/goalzero/* @tkdrob
homeassistant/components/gogogate2/* @vangorra @bdraco
homeassistant/components/google_assistant/* @home-assistant/cloud
homeassistant/components/google_cloud/* @lufton
homeassistant/components/gotify/* @benjmarshall
homeassistant/components/gpsd/* @fabaff
homeassistant/components/gree/* @cmroche
homeassistant/components/greeneye_monitor/* @jkeljo
Expand Down
30 changes: 30 additions & 0 deletions homeassistant/components/gotify/__init__.py
@@ -0,0 +1,30 @@
"""The gotify integration."""
from __future__ import annotations

from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_NAME
from homeassistant.core import HomeAssistant
from homeassistant.helpers import discovery

from .const import DOMAIN


async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up gotify from a config entry."""
hass.data.setdefault(DOMAIN, {})[entry.entry_id] = entry
# Gotify notify component requires entry_id in discovery_info to get the correct entry config.
# The discovery platform requires CONF_NAME in discovery_info to set a service name.
discovery_info = {CONF_NAME: entry.data[CONF_NAME], "entry_id": entry.entry_id}
await hass.async_create_task(
raman325 marked this conversation as resolved.
Show resolved Hide resolved
discovery.async_load_platform(
hass, "notify", DOMAIN, discovery_info, hass.data[DOMAIN]
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The string, notify, should be imported from homeassistant.const.Platform.

Eg.

from homeassistant.const import Platform

Platform.NOTIFY

)
)
return True


async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Unload a config entry."""
hass.services.async_remove("notify", entry.data[CONF_NAME])
hass.data[DOMAIN].pop(entry.entry_id)
return True
benjmarshall marked this conversation as resolved.
Show resolved Hide resolved
Comment on lines +26 to +30
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Now the standard pattern here is

async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
    """Unload a config entry."""
    if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS):
        hass.data[DOMAIN].pop(entry.entry_id)

    return unload_ok

Again, what you have probably works, but I'm not sure if updating to the new methods is required for merging.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same as above the notify platform doesn't support config entries.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yea, makes sense.

155 changes: 155 additions & 0 deletions homeassistant/components/gotify/config_flow.py
@@ -0,0 +1,155 @@
"""Config flow for gotify integration."""
from __future__ import annotations

import logging
from typing import Any

import gotify
import voluptuous as vol

from homeassistant.config_entries import ConfigFlow
from homeassistant.const import CONF_CLIENT_SECRET, CONF_HOST, CONF_NAME, CONF_TOKEN
from homeassistant.core import HomeAssistant
from homeassistant.data_entry_flow import FlowResult
from homeassistant.exceptions import HomeAssistantError
import homeassistant.helpers.config_validation as cv

from .const import DOMAIN

_LOGGER = logging.getLogger(__name__)


async def validate_input(hass: HomeAssistant, user_input: dict) -> None:
"""Validate the user input allows us to connect."""
try:
cv.url(user_input[CONF_HOST])
except vol.Invalid as error:
raise InvalidURL from error

gotify_hub = gotify.gotify(
base_url=user_input[CONF_HOST], client_token=user_input[CONF_CLIENT_SECRET]
)

try:
await hass.async_add_executor_job(gotify_hub.get_health)
except gotify.GotifyError as exc:
raise CannotConnect from exc

try:
await hass.async_add_executor_job(gotify_hub.get_applications)
except gotify.GotifyError as error:
raise InvalidAuth from error


def sanitise_name(hass: HomeAssistant, name: str) -> str:
"""Make sure we are not going to create and entry with a duplicate name as this would produce duplicate services."""

raw_name = DOMAIN + "_" + name.replace(" ", "_").lower()

if DOMAIN in hass.data:
entry_names = []
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this needs moving outside of this if statement (or the while loop needs moving inside of it), as otherwise this function errors out if the gotify domain doesn't exist.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yea, this is a bug for sure. Since the lower half of the function doesn't do anything really if entry_names is empty, I'd recommend a short circuit exit.

if DOMAIN not in hass.data:
    return name

for entry in hass.data[DOMAIN]:
entry_names.append(hass.data[DOMAIN][entry].data[CONF_NAME])
Comment on lines +51 to +52
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As this is a dictionary and you only seem to care about the values, you can instead iterate over hass.data[DOMAIN].values() directly.

You can also simplify this using list comprehension and even avoid empty list initialization.

entry_names = [entry.data[CONF_NAME] for entry in hass.data[DOMAIN].values()]


name = raw_name
i = 2
while name in entry_names:
name = raw_name + str(i)
return name
Comment on lines +54 to +58
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This will cause an infinite loop if the same name exists since you never remove the name from the entry_names list.

You're probably better off filtering by name in the list above and then using it's length here.

entry_names = [entry.data[CONF_NAME] for entry in hass.data[DOMAIN].values() if entry.data[CONF_NAME] == name]
name = raw_name + str(len(entry_names))

That said, we're not using the actual name values at all, so it could be simplified further.

def sanitise_name(hass: HomeAssistant, name: str) -> str:
    """Make sure we are not going to create and entry with a duplicate name as this would produce duplicate services."""
    num_existing_names = 1
    for entry in hass.data.get(DOMAIN, {}).values():
        if entry.data[CONF_NAME] == name:
            num_existing_names += 1

    name = DOMAIN + "_" + name.replace(" ", "_").lower()
    if num_existing_names == 1:
        return name

    return f"{name}_{num_existing_names}"



async def configure_application(hass: HomeAssistant, user_input: dict):
"""Configure and existing gotify application or create a new one."""
gotify_hub = gotify.gotify(
base_url=user_input[CONF_HOST], client_token=user_input[CONF_CLIENT_SECRET]
)

return_token = ""
return_name = ""

try:
current_applications = await hass.async_add_executor_job(
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It looks like this is only used in one block below. It can go within the if CONF_TOKEN in user_input: block.

gotify_hub.get_applications
)
if CONF_TOKEN in user_input:
for app in current_applications:
if user_input[CONF_TOKEN] == app.get("token"):
return_token = user_input[CONF_TOKEN]
return_name = app.get("name")
if not (CONF_TOKEN in user_input) or not return_token:
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You could drop not (CONF_TOKEN in user_input) and only check not return_token here as return_token is initialized as a falsy value.

new_app = await hass.async_add_executor_job(
gotify_hub.create_application, "Home Assistant"
)
return_token = new_app.get("token")
return_name = new_app.get("name")
except gotify.GotifyError as error:
raise AppSetupError from error

# Sanitise name returned from Gotify API
return_name = sanitise_name(hass, return_name)
return return_token, return_name


class GotifyConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle a config flow for gotify."""

VERSION = 1

async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> FlowResult:
"""Handle the initial step."""
errors = {}

if user_input is not None:
try:
await validate_input(self.hass, user_input)
(
user_input[CONF_TOKEN],
user_input[CONF_NAME],
) = await configure_application(self.hass, user_input)
except InvalidURL:
errors["base"] = "invalid_host"
except CannotConnect:
errors["base"] = "cannot_connect"
except InvalidAuth:
errors["base"] = "invalid_auth"
except AppSetupError:
errors["base"] = "app_setup_error"
except Exception: # pylint: disable=broad-except
_LOGGER.exception("Unexpected exception")
errors["base"] = "unknown"
else:
return self.async_create_entry(
title=user_input[CONF_NAME], data=user_input
)
else:
user_input = {}

step_user_data_scheme = vol.Schema(
{
vol.Required(CONF_HOST, default=user_input.get(CONF_HOST, "")): str,
vol.Required(CONF_CLIENT_SECRET): str,
vol.Optional(CONF_TOKEN): str,
},
)

return self.async_show_form(
step_id="user", data_schema=step_user_data_scheme, errors=errors
)


class CannotConnect(HomeAssistantError):
"""Error to indicate we cannot connect."""


class InvalidAuth(HomeAssistantError):
"""Error to indicate there is invalid auth."""


class InvalidURL(HomeAssistantError):
"""Error to indicate there is invalid host."""


class AppSetupError(HomeAssistantError):
"""Error to indicate failure to setup Gotify application."""
5 changes: 5 additions & 0 deletions homeassistant/components/gotify/const.py
@@ -0,0 +1,5 @@
"""Const for gotify."""

DOMAIN = "gotify"
ATTR_PRIORITY = "priority"
ATTR_LINK = "link"
9 changes: 9 additions & 0 deletions homeassistant/components/gotify/manifest.json
@@ -0,0 +1,9 @@
{
"domain": "gotify",
"name": "Gotify",
"documentation": "https://www.home-assistant.io/integrations/gotify",
"codeowners": ["@benjmarshall"],
"iot_class": "cloud_polling",
"requirements": ["gotify==0.2.0"],
"config_flow": true
}
57 changes: 57 additions & 0 deletions homeassistant/components/gotify/notify.py
@@ -0,0 +1,57 @@
"""Gotify platform for notify component."""
import logging

import gotify

from homeassistant.components.notify import (
ATTR_DATA,
ATTR_TITLE,
BaseNotificationService,
)
from homeassistant.const import CONF_HOST, CONF_TOKEN

from .const import ATTR_LINK, ATTR_PRIORITY, DOMAIN

_LOGGER = logging.getLogger(__name__)


def get_service(hass, config, discovery_info=None):
"""Get the Gotify notification service."""
config_entry = hass.data[DOMAIN][discovery_info["entry_id"]]
return GotifyNotificationService(
config_entry.data[CONF_TOKEN], config_entry.data[CONF_HOST]
)


class GotifyNotificationService(BaseNotificationService):
"""Implement the notification service for Gotify."""

def __init__(self, token, url):
"""Initialize the service."""
self.token = token
self.url = url
self.gotify = gotify.gotify(
base_url=url,
app_token=token,
)

def send_message(self, message, **kwargs):
"""Send a message."""
data = kwargs.get(ATTR_DATA) or {}
title = kwargs.get(ATTR_TITLE) or None
priority = data.get(ATTR_PRIORITY) or 4
link = data.get(ATTR_LINK) or ""

extras = {
"client::display": {"contentType": "text/markdown"},
"client::notification": {
"click": {"url": "homeassistant://navigate/" + link}
},
}

try:
self.gotify.create_message(
message, title=title, priority=priority, extras=extras
)
except gotify.GotifyError as exception:
_LOGGER.error("Send message failed: %s", str(exception))
20 changes: 20 additions & 0 deletions homeassistant/components/gotify/strings.json
@@ -0,0 +1,20 @@
{
"config": {
"step": {
"user": {
"data": {
"host": "[%key:common::config_flow::data::host%]",
"client_secret": "Client Token",
"token": "App Token"
}
}
},
"error": {
"invalid_host": "[%key:common::config_flow::error::invalid_host%]",
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"invalid_auth": "[%key:common::config_flow::error::invalid_auth%]",
"app_setup_error": "Cannot create or setup gotify application",
"unknown": "[%key:common::config_flow::error::unknown%]"
}
}
}
20 changes: 20 additions & 0 deletions homeassistant/components/gotify/translations/en.json
@@ -0,0 +1,20 @@
{
benjmarshall marked this conversation as resolved.
Show resolved Hide resolved
"config": {
"error": {
"app_setup_error": "Cannot create or setup gotify application",
"cannot_connect": "Failed to connect",
"invalid_auth": "Invalid authentication",
"invalid_host": "Invalid hostname or IP address",
"unknown": "Unexpected error"
},
"step": {
"user": {
"data": {
"client_secret": "Client Token",
"host": "Host",
"token": "App Token"
}
}
}
}
}
1 change: 1 addition & 0 deletions homeassistant/generated/config_flows.py
Expand Up @@ -97,6 +97,7 @@
"goalzero",
"gogogate2",
"google_travel_time",
"gotify",
"gpslogger",
"gree",
"growatt_server",
Expand Down
3 changes: 3 additions & 0 deletions requirements_all.txt
Expand Up @@ -708,6 +708,9 @@ googlemaps==2.5.1
# homeassistant.components.slide
goslide-api==0.5.1

# homeassistant.components.gotify
gotify==0.2.0

# homeassistant.components.remote_rpi_gpio
gpiozero==1.5.1

Expand Down
3 changes: 3 additions & 0 deletions requirements_test_all.txt
Expand Up @@ -402,6 +402,9 @@ google-nest-sdm==0.2.12
# homeassistant.components.google_travel_time
googlemaps==2.5.1

# homeassistant.components.gotify
gotify==0.2.0

# homeassistant.components.gree
greeclimate==0.11.7

Expand Down
1 change: 1 addition & 0 deletions tests/components/gotify/__init__.py
@@ -0,0 +1 @@
"""Tests for the gotify integration."""