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
Changes from all commits
8a0405e
d539c1b
3014987
9bcd12e
81aa5c5
deee0ba
8a9a3da
389f188
99f8025
fc9ef9e
f81fcad
9b681a5
efbf5fd
5bb5aac
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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) | ||
_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") | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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]: | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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) | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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.""" |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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"} | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 | ||
) |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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 |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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" | ||
] | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,13 @@ | ||
search: | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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' |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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%]" | ||
} | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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" | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -134,6 +134,7 @@ | |
"omnilogic", | ||
"onewire", | ||
"onvif", | ||
"openplantbook", | ||
"opentherm_gw", | ||
"openuv", | ||
"openweathermap", | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1 @@ | ||
"""Tests for the OpenPlantBook integration.""" |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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", {}) | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 |
There was a problem hiding this comment.
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.