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 18 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
28 changes: 28 additions & 0 deletions homeassistant/components/gotify/__init__.py
@@ -0,0 +1,28 @@
"""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.data[CONF_NAME]] = entry
benjmarshall marked this conversation as resolved.
Show resolved Hide resolved
discovery_info = {CONF_NAME: entry.data[CONF_NAME]}
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.data[CONF_NAME])
return True
benjmarshall marked this conversation as resolved.
Show resolved Hide resolved
98 changes: 98 additions & 0 deletions homeassistant/components/gotify/config_flow.py
@@ -0,0 +1,98 @@
"""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_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, host: str, token: str) -> None:
"""Validate the user input allows us to connect."""
try:
cv.url(host)
except vol.Invalid as error:
raise InvalidURL from error

gotify_hub = gotify.gotify(base_url=host, app_token=token, client_token=token)

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.create_message, "Home Assistant has been authenticated."
)
except gotify.GotifyError as error:
raise InvalidAuth from error


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[CONF_HOST], user_input[CONF_TOKEN]
)
except InvalidURL:
errors["base"] = "invalid_host"
except CannotConnect:
errors["base"] = "cannot_connect"
except InvalidAuth:
errors["base"] = "invalid_auth"
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_SCHEMA = vol.Schema(
benjmarshall marked this conversation as resolved.
Show resolved Hide resolved
{
vol.Required(CONF_HOST, default=user_input.get(CONF_HOST, "")): str,
vol.Required(CONF_TOKEN, default=user_input.get(CONF_TOKEN, "")): str,
benjmarshall marked this conversation as resolved.
Show resolved Hide resolved
vol.Required(CONF_NAME, default=user_input.get(CONF_NAME, "")): str,
benjmarshall marked this conversation as resolved.
Show resolved Hide resolved
},
)

return self.async_show_form(
step_id="user", data_schema=STEP_USER_DATA_SCHEMA, 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."""
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_NAME, 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[CONF_NAME]]
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))
19 changes: 19 additions & 0 deletions homeassistant/components/gotify/strings.json
@@ -0,0 +1,19 @@
{
"config": {
"step": {
"user": {
"data": {
"host": "[%key:common::config_flow::data::host%]",
"token": "[%key:common::config_flow::data::api_token%]",
"name": "[%key:common::config_flow::data::name%]"
}
}
},
"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%]",
"unknown": "[%key:common::config_flow::error::unknown%]"
}
}
}
19 changes: 19 additions & 0 deletions homeassistant/components/gotify/translations/en.json
@@ -0,0 +1,19 @@
{
benjmarshall marked this conversation as resolved.
Show resolved Hide resolved
"config": {
"error": {
"cannot_connect": "Failed to connect",
"invalid_auth": "Invalid authentication",
"invalid_host": "Invalid hostname or IP address",
"unknown": "Unexpected error"
},
"step": {
"user": {
"data": {
"host": "Host",
"name": "Name",
"token": "API 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."""
90 changes: 90 additions & 0 deletions tests/components/gotify/test_config_flow.py
@@ -0,0 +1,90 @@
"""Test the gotify config flow."""
from unittest.mock import patch

from homeassistant import config_entries, setup
from homeassistant.components.gotify.config_flow import CannotConnect, InvalidAuth
from homeassistant.components.gotify.const import DOMAIN
from homeassistant.core import HomeAssistant
from homeassistant.data_entry_flow import RESULT_TYPE_CREATE_ENTRY, RESULT_TYPE_FORM


async def test_form(hass: HomeAssistant) -> None:
"""Test we get the form."""
await setup.async_setup_component(hass, "persistent_notification", {})
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
assert result["type"] == RESULT_TYPE_FORM
assert result["errors"] == {}

with patch("homeassistant.components.gotify.config_flow.validate_input"), patch(
"homeassistant.components.gotify.async_setup_entry",
return_value=True,
) as mock_setup_entry:
result2 = await hass.config_entries.flow.async_configure(
result["flow_id"],
{"host": "https://1.1.1.1", "token": "test-token", "name": "test"},
Copy link
Contributor

Choose a reason for hiding this comment

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

Minor nit: 1.1.1.1 is a real public IP address. example.com is reserved for these purposes.

)
await hass.async_block_till_done()

assert result2["type"] == RESULT_TYPE_CREATE_ENTRY
assert result2["title"] == "test"
assert result2["data"] == {
"host": "https://1.1.1.1",
"token": "test-token",
"name": "test",
}
assert len(mock_setup_entry.mock_calls) == 1


async def test_form_invalid_auth(hass: HomeAssistant) -> None:
"""Test we handle invalid auth."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)

with patch(
"homeassistant.components.gotify.config_flow.validate_input",
side_effect=InvalidAuth,
):
result2 = await hass.config_entries.flow.async_configure(
result["flow_id"],
{"host": "https://1.1.1.1", "token": "test-token", "name": "test"},
)

assert result2["type"] == RESULT_TYPE_FORM
assert result2["errors"] == {"base": "invalid_auth"}


async def test_form_cannot_connect(hass: HomeAssistant) -> None:
"""Test we handle cannot connect error."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)

with patch(
"homeassistant.components.gotify.config_flow.validate_input",
side_effect=CannotConnect,
):
result2 = await hass.config_entries.flow.async_configure(
result["flow_id"],
{"host": "https://1.1.1.1", "token": "test-token", "name": "test"},
)

assert result2["type"] == RESULT_TYPE_FORM
assert result2["errors"] == {"base": "cannot_connect"}


async def test_invalid_host(hass: HomeAssistant) -> None:
"""Test we handle invalid host."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)

result2 = await hass.config_entries.flow.async_configure(
result["flow_id"],
{"host": "1.1.1.1", "token": "test-token", "name": "test"},
Copy link
Contributor

Choose a reason for hiding this comment

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

This is a semantic thing, but technically this is a host address, though it is not a URI. https://www.sistrix.com/ask-sistrix/technical-seo/site-structure/what-is-the-difference-between-a-url-domain-subdomain-hostname-etc

)

assert result2["type"] == RESULT_TYPE_FORM
assert result2["errors"] == {"base": "invalid_host"}