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 Openplantbook integration #42359

Closed
wants to merge 14 commits into from
1 change: 1 addition & 0 deletions .coveragerc
Expand Up @@ -621,6 +621,7 @@ omit =
homeassistant/components/openhome/__init__.py
homeassistant/components/openhome/media_player.py
homeassistant/components/openhome/const.py
homeassistant/components/openplantbook/__init__.py
homeassistant/components/opensensemap/air_quality.py
homeassistant/components/opensky/sensor.py
homeassistant/components/opentherm_gw/__init__.py
Expand Down
1 change: 1 addition & 0 deletions CODEOWNERS
Validating CODEOWNERS rules …
Expand Up @@ -309,6 +309,7 @@ homeassistant/components/onewire/* @garbled1 @epenet
homeassistant/components/onvif/* @hunterjm
homeassistant/components/openerz/* @misialq
homeassistant/components/opengarage/* @danielhiversen
homeassistant/components/openplantbook/* @Olen
homeassistant/components/opentherm_gw/* @mvn23
homeassistant/components/openuv/* @bachya
homeassistant/components/openweathermap/* @fabaff @freekode
Expand Down
135 changes: 135 additions & 0 deletions homeassistant/components/openplantbook/__init__.py
@@ -0,0 +1,135 @@
"""The OpenPlantBook integration."""
import asyncio
from datetime import datetime, timedelta
import logging

from pyopenplantbook import OpenPlantBookApi
import voluptuous as vol

from homeassistant import exceptions
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity import async_generate_entity_id

from .const import ATTR_ALIAS, ATTR_API, ATTR_HOURS, ATTR_SPECIES, CACHE_TIME, DOMAIN

CONFIG_SCHEMA = vol.Schema({DOMAIN: vol.Schema({})}, extra=vol.ALLOW_EXTRA)
Copy link
Contributor

Choose a reason for hiding this comment

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

Since this is configured via config_flow, the CONFIG_SCHEMA shouldn't be needed.

_LOGGER = logging.getLogger(__name__)


async def async_setup(hass: HomeAssistant, config: dict):
"""Set up the OpenPlantBook component."""
return True


async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry):
"""Set up OpenPlantBook from a config entry."""

if DOMAIN not in hass.data:
hass.data[DOMAIN] = {}
if ATTR_API not in hass.data[DOMAIN]:
hass.data[DOMAIN][ATTR_API] = OpenPlantBookApi(
entry.data.get("client_id"), entry.data.get("secret")
Copy link
Contributor

Choose a reason for hiding this comment

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

Throughout the component there are magic strings that are used in multiple places. It is very common in HA for these all to be defined as constants so there is less chance for mismatching typos. There are already a bunch of these in const.py but there are many that are not.

)
if ATTR_SPECIES not in hass.data[DOMAIN]:
hass.data[DOMAIN][ATTR_SPECIES] = {}

async def get_plant(call):
species = call.data.get(ATTR_SPECIES)
if species:
# Here we try to ensure that we only run one API request for each species
# The first process creates an empty dict, and access the API
# Later requests for the same species either wait for the first one to complete
# or they returns immediately if we already have the data we need
_LOGGER.debug("get_plant %s", species)
if species not in hass.data[DOMAIN][ATTR_SPECIES]:
_LOGGER.debug("I am the first process to get %s", species)
hass.data[DOMAIN][ATTR_SPECIES][species] = {}
elif "pid" not in hass.data[DOMAIN][ATTR_SPECIES][species]:
# If more than one "get_plant" is triggered for the same species, we wait for up to
# 10 seconds for the first process to complete the API request.
# We don't want to return immediately, as we want the state object to be set by
# the running process before we return from this call
_LOGGER.debug(
"Another process is currently trying to get the data for %s...",
species,
)
wait = 0
while "pid" not in hass.data[DOMAIN][ATTR_SPECIES][species]:
Copy link
Contributor

Choose a reason for hiding this comment

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

asyncio.Event would be a good replacement for this retry loop.

_LOGGER.debug("Waiting...")
wait = wait + 1
if wait == 10:
_LOGGER.error("Giving up waiting for OpenPlantBook")
return False
await asyncio.sleep(1)
_LOGGER.debug("The other process completed successfully")
return True
elif datetime.now() < datetime.fromisoformat(
hass.data[DOMAIN][ATTR_SPECIES][species]["timestamp"]
) + timedelta(hours=CACHE_TIME):
# We already have the data we need, so let's just return
_LOGGER.debug("We already have cached data for %s", species)
return True

plant_data = await hass.data[DOMAIN][ATTR_API].get_plantbook_data(species)
if plant_data:
_LOGGER.debug("Got data for %s", species)
plant_data["timestamp"] = datetime.now().isoformat()
hass.data[DOMAIN][ATTR_SPECIES][species] = plant_data
attrs = {}
for var, val in plant_data.items():
attrs[var] = val
entity_id = async_generate_entity_id(
f"{DOMAIN}" + ".{}", plant_data["pid"], current_ids={}
)
hass.states.async_set(entity_id, plant_data["display_pid"], attrs)
Copy link
Contributor

Choose a reason for hiding this comment

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

I'm not familiar with anything in the plant area so take this comment with a grain of salt, but it feels unusual that services are creating and destroying entities. In the light/switch/sensor/etc world it'd be more traditional for the config_entry to know about what entities will be created and for them all to be created during setup.


async def search_plantbook(call):
alias = call.data.get(ATTR_ALIAS)
if alias:
plant_data = await hass.data[DOMAIN][ATTR_API].search_plantbook(alias)
state = len(plant_data["results"])
attrs = {}
for plant in plant_data["results"]:
pid = plant["pid"]
attrs[pid] = plant["display_pid"]
hass.states.async_set(f"{DOMAIN}.search_result", state, attrs)

async def clean_cache(call):
hours = call.data.get(ATTR_HOURS)
if not hours:
hours = CACHE_TIME
if ATTR_SPECIES in hass.data[DOMAIN]:
for species in list(hass.data[DOMAIN][ATTR_SPECIES]):
value = hass.data[DOMAIN][ATTR_SPECIES][species]
if datetime.now() > datetime.fromisoformat(
value["timestamp"]
) + timedelta(hours=hours):
_LOGGER.debug("Removing %s from cache", species)
entity_id = async_generate_entity_id(
f"{DOMAIN}" + ".{}", value["pid"], current_ids={}
)
hass.states.async_remove(entity_id)
hass.data[DOMAIN][ATTR_SPECIES].pop(species)

hass.services.async_register(DOMAIN, "search", search_plantbook)
hass.services.async_register(DOMAIN, "get", get_plant)
hass.services.async_register(DOMAIN, "clean_cache", clean_cache)
hass.states.async_set(f"{DOMAIN}.search_result", 0)

return True


async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry):
"""Unload a config entry."""
hass.data.pop(DOMAIN)

return True


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


class InvalidAuth(exceptions.HomeAssistantError):
"""Error to indicate there is invalid auth."""
54 changes: 54 additions & 0 deletions homeassistant/components/openplantbook/config_flow.py
@@ -0,0 +1,54 @@
"""Config flow for OpenPlantBook integration."""
import logging

import voluptuous as vol

from homeassistant import config_entries, core

from . import OpenPlantBookApi
from .const import ATTR_API, DOMAIN

_LOGGER = logging.getLogger(__name__)

DATA_SCHEMA = vol.Schema({"client_id": str, "secret": str})


async def validate_input(hass: core.HomeAssistant, data):
"""Validate the user input allows us to connect.

Data has the keys from DATA_SCHEMA with values provided by the user.
"""

if DOMAIN not in hass.data:
hass.data[DOMAIN] = {}
try:
hass.data[DOMAIN][ATTR_API] = OpenPlantBookApi(
data["client_id"], data["secret"]
)
except Exception as ex:
_LOGGER.debug("Unable to connect to OpenPlantbook: %s", ex)
raise

return {"title": "Openplantbook API"}
Copy link
Contributor

Choose a reason for hiding this comment

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

Nitpick: There are three different capitalizations of OpenPlantBook in this file. Should they all be replaced with one, correct branding?



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

VERSION = 1
CONNECTION_CLASS = config_entries.CONN_CLASS_UNKNOWN

async def async_step_user(self, user_input=None):
"""Handle the initial step."""
errors = {}
if user_input is not None:
try:
info = await validate_input(self.hass, user_input)
return self.async_create_entry(title=info["title"], data=user_input)
except Exception as ex:
_LOGGER.error("Unable to connect to OpenPlantbook: %s", ex)
raise
Copy link
Contributor

Choose a reason for hiding this comment

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

An exception while validating input/trying to connect would typically be given back to the user by showing the form again with the the errors field set. This lets the user fix their input and try again. I'm not sure what the user experience of a thrown exception during config flow would be.


return self.async_show_form(
step_id="user", data_schema=DATA_SCHEMA, errors=errors
)
8 changes: 8 additions & 0 deletions homeassistant/components/openplantbook/const.py
@@ -0,0 +1,8 @@
"""Constants for the openplantbook integration."""
DOMAIN = "openplantbook"
PLANTBOOK_BASEURL = "https://open.plantbook.io/api/v1"
ATTR_ALIAS = "alias"
ATTR_SPECIES = "species"
ATTR_API = "api"
ATTR_HOURS = "hours"
CACHE_TIME = 24
14 changes: 14 additions & 0 deletions homeassistant/components/openplantbook/manifest.json
@@ -0,0 +1,14 @@
{
"domain": "openplantbook",
"name": "OpenPlantBook",
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/openplantbook",
"requirements": ["pyopenplantbook==0.0.4"],
"ssdp": [],
"zeroconf": [],
"homekit": {},
"dependencies": [],
"codeowners": [
"@Olen"
]
}
13 changes: 13 additions & 0 deletions homeassistant/components/openplantbook/services.yaml
@@ -0,0 +1,13 @@
search:
Copy link
Contributor

Choose a reason for hiding this comment

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

The clear_cache service isn't listed here

description: Searches Openplantbook for a plant
fields:
alias:
description: The string to search for
example: Capsicum

get:
description: Fetches data for a single species
fields:
species:
description: The name of the species as written in "pid" in Openplantbook
example: coleus 'marble'
22 changes: 22 additions & 0 deletions homeassistant/components/openplantbook/strings.json
@@ -0,0 +1,22 @@
{
"title": "OpenPlantBook",
"config": {
"step": {
"user": {
"data": {
"client_id": "Client ID",
"secret": "Secret"
},
"description": "Log in to https://open.plantbook.io/apikey/show/ and get you API client id and secret."
}
},
"error": {
"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%]"
},
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]"
}
}
}
22 changes: 22 additions & 0 deletions homeassistant/components/openplantbook/translations/en.json
@@ -0,0 +1,22 @@
{
"config": {
"abort": {
"already_configured": "Device is already configured"
},
"error": {
"cannot_connect": "Failed to connect",
"invalid_auth": "Invalid authentication",
"unknown": "Unexpected error"
},
"step": {
"user": {
"data": {
"client_id": "Client ID",
"secret": "Secret"
},
"description": "Log in to https://open.plantbook.io/apikey/show/ and get you API client id and secret."
}
}
},
"title": "OpenPlantBook"
}
1 change: 1 addition & 0 deletions homeassistant/generated/config_flows.py
Expand Up @@ -134,6 +134,7 @@
"omnilogic",
"onewire",
"onvif",
"openplantbook",
"opentherm_gw",
"openuv",
"openweathermap",
Expand Down
3 changes: 3 additions & 0 deletions requirements_all.txt
Expand Up @@ -1562,6 +1562,9 @@ pyobihai==1.2.3
# homeassistant.components.ombi
pyombi==0.1.10

# homeassistant.components.openplantbook
pyopenplantbook==0.0.4

# homeassistant.components.openuv
pyopenuv==1.0.9

Expand Down
3 changes: 3 additions & 0 deletions requirements_test_all.txt
Expand Up @@ -769,6 +769,9 @@ pynx584==0.5
# homeassistant.components.nzbget
pynzbgetapi==0.2.0

# homeassistant.components.openplantbook
pyopenplantbook==0.0.4

# homeassistant.components.openuv
pyopenuv==1.0.9

Expand Down
1 change: 1 addition & 0 deletions tests/components/openplantbook/__init__.py
@@ -0,0 +1 @@
"""Tests for the OpenPlantBook integration."""
71 changes: 71 additions & 0 deletions tests/components/openplantbook/test_config_flow.py
@@ -0,0 +1,71 @@
"""Test the OpenPlantBook config flow."""
from homeassistant import config_entries, setup
from homeassistant.components.openplantbook.const import DOMAIN

from tests.async_mock import patch


async def test_form(hass):
"""Test we get the form."""
await setup.async_setup_component(hass, "persistent_notification", {})
Copy link
Contributor

Choose a reason for hiding this comment

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

The component doesn't interact with persistent_notification. Does it need to be initialized in the tests?

result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
assert result["type"] == "form"
assert result["errors"] == {}

with patch(
"homeassistant.components.openplantbook.async_setup", return_value=True
) as mock_setup, patch(
"homeassistant.components.openplantbook.async_setup_entry",
return_value=True,
) as mock_setup_entry:
result2 = await hass.config_entries.flow.async_configure(
result["flow_id"],
{
"client_id": "test-client-id",
"secret": "test-secret",
},
)

assert result2["type"] == "create_entry"
assert result2["title"] == "Openplantbook API"
assert result2["data"] == {
"client_id": "test-client-id",
"secret": "test-secret",
}
await hass.async_block_till_done()
assert len(mock_setup.mock_calls) == 1
assert len(mock_setup_entry.mock_calls) == 1


async def test_error(hass):
"""Test if something fails."""
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"] == "form"
assert result["errors"] == {}

with patch(
"homeassistant.components.openplantbook.async_setup", return_value=True
) as mock_setup, patch(
"homeassistant.components.openplantbook.async_setup_entry",
return_value=True,
) as mock_setup_entry:
try:
result2 = await hass.config_entries.flow.async_configure(
result["flow_id"],
{
"client_id": "test-client-id",
},
)
except KeyError:
result2 = None
pass

await hass.async_block_till_done()
assert result2 is None
assert len(mock_setup.mock_calls) == 0
assert len(mock_setup_entry.mock_calls) == 0