Add support for Keg and Airlock to Plaato using polling API (#34760)

Co-authored-by: J. Nick Koston <nick@koston.org>
This commit is contained in:
Johan Nenzén 2021-02-01 18:12:56 +01:00 committed by GitHub
parent 83a75b02ea
commit 285bd3aa91
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
14 changed files with 1019 additions and 197 deletions

View File

@ -702,7 +702,11 @@ omit =
homeassistant/components/ping/device_tracker.py homeassistant/components/ping/device_tracker.py
homeassistant/components/pioneer/media_player.py homeassistant/components/pioneer/media_player.py
homeassistant/components/pjlink/media_player.py homeassistant/components/pjlink/media_player.py
homeassistant/components/plaato/* homeassistant/components/plaato/__init__.py
homeassistant/components/plaato/binary_sensor.py
homeassistant/components/plaato/const.py
homeassistant/components/plaato/entity.py
homeassistant/components/plaato/sensor.py
homeassistant/components/plex/media_player.py homeassistant/components/plex/media_player.py
homeassistant/components/plum_lightpad/light.py homeassistant/components/plum_lightpad/light.py
homeassistant/components/pocketcasts/sensor.py homeassistant/components/pocketcasts/sensor.py

View File

@ -1,11 +1,34 @@
"""Support for Plaato Airlock.""" """Support for Plaato devices."""
import asyncio
from datetime import timedelta
import logging import logging
from aiohttp import web from aiohttp import web
from pyplaato.models.airlock import PlaatoAirlock
from pyplaato.plaato import (
ATTR_ABV,
ATTR_BATCH_VOLUME,
ATTR_BPM,
ATTR_BUBBLES,
ATTR_CO2_VOLUME,
ATTR_DEVICE_ID,
ATTR_DEVICE_NAME,
ATTR_OG,
ATTR_SG,
ATTR_TEMP,
ATTR_TEMP_UNIT,
ATTR_VOLUME_UNIT,
Plaato,
PlaatoDeviceType,
)
import voluptuous as vol import voluptuous as vol
from homeassistant.components.sensor import DOMAIN as SENSOR from homeassistant.components.sensor import DOMAIN as SENSOR
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import ( from homeassistant.const import (
CONF_SCAN_INTERVAL,
CONF_TOKEN,
CONF_WEBHOOK_ID, CONF_WEBHOOK_ID,
HTTP_OK, HTTP_OK,
TEMP_CELSIUS, TEMP_CELSIUS,
@ -13,31 +36,33 @@ from homeassistant.const import (
VOLUME_GALLONS, VOLUME_GALLONS,
VOLUME_LITERS, VOLUME_LITERS,
) )
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import ConfigEntryNotReady
from homeassistant.helpers import aiohttp_client
import homeassistant.helpers.config_validation as cv import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.helpers.dispatcher import async_dispatcher_send
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
from .const import DOMAIN from .const import (
CONF_DEVICE_NAME,
CONF_DEVICE_TYPE,
CONF_USE_WEBHOOK,
COORDINATOR,
DEFAULT_SCAN_INTERVAL,
DEVICE,
DEVICE_ID,
DEVICE_NAME,
DEVICE_TYPE,
DOMAIN,
PLATFORMS,
SENSOR_DATA,
UNDO_UPDATE_LISTENER,
)
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
DEPENDENCIES = ["webhook"] DEPENDENCIES = ["webhook"]
PLAATO_DEVICE_SENSORS = "sensors"
PLAATO_DEVICE_ATTRS = "attrs"
ATTR_DEVICE_ID = "device_id"
ATTR_DEVICE_NAME = "device_name"
ATTR_TEMP_UNIT = "temp_unit"
ATTR_VOLUME_UNIT = "volume_unit"
ATTR_BPM = "bpm"
ATTR_TEMP = "temp"
ATTR_SG = "sg"
ATTR_OG = "og"
ATTR_BUBBLES = "bubbles"
ATTR_ABV = "abv"
ATTR_CO2_VOLUME = "co2_volume"
ATTR_BATCH_VOLUME = "batch_volume"
SENSOR_UPDATE = f"{DOMAIN}_sensor_update" SENSOR_UPDATE = f"{DOMAIN}_sensor_update"
SENSOR_DATA_KEY = f"{DOMAIN}.{SENSOR}" SENSOR_DATA_KEY = f"{DOMAIN}.{SENSOR}"
@ -60,31 +85,124 @@ WEBHOOK_SCHEMA = vol.Schema(
) )
async def async_setup(hass, hass_config): async def async_setup(hass: HomeAssistant, config: dict):
"""Set up the Plaato component.""" """Set up the Plaato component."""
hass.data.setdefault(DOMAIN, {})
return True return True
async def async_setup_entry(hass, entry): async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry):
"""Configure based on config entry.""" """Configure based on config entry."""
if DOMAIN not in hass.data:
hass.data[DOMAIN] = {}
use_webhook = entry.data[CONF_USE_WEBHOOK]
if use_webhook:
async_setup_webhook(hass, entry)
else:
await async_setup_coordinator(hass, entry)
for platform in PLATFORMS:
if entry.options.get(platform, True):
hass.async_create_task(
hass.config_entries.async_forward_entry_setup(entry, platform)
)
return True
@callback
def async_setup_webhook(hass: HomeAssistant, entry: ConfigEntry):
"""Init webhook based on config entry."""
webhook_id = entry.data[CONF_WEBHOOK_ID] webhook_id = entry.data[CONF_WEBHOOK_ID]
hass.components.webhook.async_register(DOMAIN, "Plaato", webhook_id, handle_webhook) device_name = entry.data[CONF_DEVICE_NAME]
hass.async_create_task(hass.config_entries.async_forward_entry_setup(entry, SENSOR)) _set_entry_data(entry, hass)
return True hass.components.webhook.async_register(
DOMAIN, f"{DOMAIN}.{device_name}", webhook_id, handle_webhook
)
async def async_unload_entry(hass, entry): async def async_setup_coordinator(hass: HomeAssistant, entry: ConfigEntry):
"""Init auth token based on config entry."""
auth_token = entry.data[CONF_TOKEN]
device_type = entry.data[CONF_DEVICE_TYPE]
if entry.options.get(CONF_SCAN_INTERVAL):
update_interval = timedelta(minutes=entry.options[CONF_SCAN_INTERVAL])
else:
update_interval = timedelta(minutes=DEFAULT_SCAN_INTERVAL)
coordinator = PlaatoCoordinator(hass, auth_token, device_type, update_interval)
await coordinator.async_refresh()
if not coordinator.last_update_success:
raise ConfigEntryNotReady
_set_entry_data(entry, hass, coordinator, auth_token)
for platform in PLATFORMS:
if entry.options.get(platform, True):
coordinator.platforms.append(platform)
def _set_entry_data(entry, hass, coordinator=None, device_id=None):
device = {
DEVICE_NAME: entry.data[CONF_DEVICE_NAME],
DEVICE_TYPE: entry.data[CONF_DEVICE_TYPE],
DEVICE_ID: device_id,
}
hass.data[DOMAIN][entry.entry_id] = {
COORDINATOR: coordinator,
DEVICE: device,
SENSOR_DATA: None,
UNDO_UPDATE_LISTENER: entry.add_update_listener(_async_update_listener),
}
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry):
"""Unload a config entry.""" """Unload a config entry."""
hass.components.webhook.async_unregister(entry.data[CONF_WEBHOOK_ID]) use_webhook = entry.data[CONF_USE_WEBHOOK]
hass.data[SENSOR_DATA_KEY]() hass.data[DOMAIN][entry.entry_id][UNDO_UPDATE_LISTENER]()
await hass.config_entries.async_forward_entry_unload(entry, SENSOR) if use_webhook:
return True return await async_unload_webhook(hass, entry)
return await async_unload_coordinator(hass, entry)
async def async_unload_webhook(hass: HomeAssistant, entry: ConfigEntry):
"""Unload webhook based entry."""
if entry.data[CONF_WEBHOOK_ID] is not None:
hass.components.webhook.async_unregister(entry.data[CONF_WEBHOOK_ID])
return await async_unload_platforms(hass, entry, PLATFORMS)
async def async_unload_coordinator(hass: HomeAssistant, entry: ConfigEntry):
"""Unload auth token based entry."""
coordinator = hass.data[DOMAIN][entry.entry_id][COORDINATOR]
return await async_unload_platforms(hass, entry, coordinator.platforms)
async def async_unload_platforms(hass: HomeAssistant, entry: ConfigEntry, platforms):
"""Unload platforms."""
unloaded = all(
await asyncio.gather(
*[
hass.config_entries.async_forward_entry_unload(entry, platform)
for platform in platforms
]
)
)
if unloaded:
hass.data[DOMAIN].pop(entry.entry_id)
return unloaded
async def _async_update_listener(hass: HomeAssistant, entry: ConfigEntry):
"""Handle options update."""
await hass.config_entries.async_reload(entry.entry_id)
async def handle_webhook(hass, webhook_id, request): async def handle_webhook(hass, webhook_id, request):
@ -96,31 +214,9 @@ async def handle_webhook(hass, webhook_id, request):
return return
device_id = _device_id(data) device_id = _device_id(data)
sensor_data = PlaatoAirlock.from_web_hook(data)
attrs = { async_dispatcher_send(hass, SENSOR_UPDATE, *(device_id, sensor_data))
ATTR_DEVICE_NAME: data.get(ATTR_DEVICE_NAME),
ATTR_DEVICE_ID: data.get(ATTR_DEVICE_ID),
ATTR_TEMP_UNIT: data.get(ATTR_TEMP_UNIT),
ATTR_VOLUME_UNIT: data.get(ATTR_VOLUME_UNIT),
}
sensors = {
ATTR_TEMP: data.get(ATTR_TEMP),
ATTR_BPM: data.get(ATTR_BPM),
ATTR_SG: data.get(ATTR_SG),
ATTR_OG: data.get(ATTR_OG),
ATTR_ABV: data.get(ATTR_ABV),
ATTR_CO2_VOLUME: data.get(ATTR_CO2_VOLUME),
ATTR_BATCH_VOLUME: data.get(ATTR_BATCH_VOLUME),
ATTR_BUBBLES: data.get(ATTR_BUBBLES),
}
hass.data[DOMAIN][device_id] = {
PLAATO_DEVICE_ATTRS: attrs,
PLAATO_DEVICE_SENSORS: sensors,
}
async_dispatcher_send(hass, SENSOR_UPDATE, device_id)
return web.Response(text=f"Saving status for {device_id}", status=HTTP_OK) return web.Response(text=f"Saving status for {device_id}", status=HTTP_OK)
@ -128,3 +224,35 @@ async def handle_webhook(hass, webhook_id, request):
def _device_id(data): def _device_id(data):
"""Return name of device sensor.""" """Return name of device sensor."""
return f"{data.get(ATTR_DEVICE_NAME)}_{data.get(ATTR_DEVICE_ID)}" return f"{data.get(ATTR_DEVICE_NAME)}_{data.get(ATTR_DEVICE_ID)}"
class PlaatoCoordinator(DataUpdateCoordinator):
"""Class to manage fetching data from the API."""
def __init__(
self,
hass,
auth_token,
device_type: PlaatoDeviceType,
update_interval: timedelta,
):
"""Initialize."""
self.api = Plaato(auth_token=auth_token)
self.hass = hass
self.device_type = device_type
self.platforms = []
super().__init__(
hass,
_LOGGER,
name=DOMAIN,
update_interval=update_interval,
)
async def _async_update_data(self):
"""Update data via library."""
data = await self.api.get_data(
session=aiohttp_client.async_get_clientsession(self.hass),
device_type=self.device_type,
)
return data

View File

@ -0,0 +1,56 @@
"""Support for Plaato Airlock sensors."""
import logging
from pyplaato.plaato import PlaatoKeg
from homeassistant.components.binary_sensor import (
DEVICE_CLASS_OPENING,
DEVICE_CLASS_PROBLEM,
BinarySensorEntity,
)
from .const import CONF_USE_WEBHOOK, COORDINATOR, DOMAIN
from .entity import PlaatoEntity
_LOGGER = logging.getLogger(__name__)
async def async_setup_entry(hass, config_entry, async_add_entities):
"""Set up Plaato from a config entry."""
if config_entry.data[CONF_USE_WEBHOOK]:
return False
coordinator = hass.data[DOMAIN][config_entry.entry_id][COORDINATOR]
async_add_entities(
PlaatoBinarySensor(
hass.data[DOMAIN][config_entry.entry_id],
sensor_type,
coordinator,
)
for sensor_type in coordinator.data.binary_sensors.keys()
)
return True
class PlaatoBinarySensor(PlaatoEntity, BinarySensorEntity):
"""Representation of a Binary Sensor."""
@property
def is_on(self):
"""Return true if the binary sensor is on."""
if self._coordinator is not None:
return self._coordinator.data.binary_sensors.get(self._sensor_type)
return False
@property
def device_class(self):
"""Return the class of this device, from component DEVICE_CLASSES."""
if self._coordinator is None:
return None
if self._sensor_type is PlaatoKeg.Pins.LEAK_DETECTION:
return DEVICE_CLASS_PROBLEM
if self._sensor_type is PlaatoKeg.Pins.POURING:
return DEVICE_CLASS_OPENING

View File

@ -1,10 +1,223 @@
"""Config flow for GPSLogger.""" """Config flow for Plaato."""
from homeassistant.helpers import config_entry_flow import logging
from .const import DOMAIN from pyplaato.plaato import PlaatoDeviceType
import voluptuous as vol
config_entry_flow.register_webhook_flow( from homeassistant import config_entries
DOMAIN, from homeassistant.config_entries import ConfigEntry
"Webhook", from homeassistant.const import CONF_SCAN_INTERVAL, CONF_TOKEN, CONF_WEBHOOK_ID
{"docs_url": "https://www.home-assistant.io/integrations/plaato/"}, from homeassistant.core import callback
import homeassistant.helpers.config_validation as cv
from .const import (
CONF_CLOUDHOOK,
CONF_DEVICE_NAME,
CONF_DEVICE_TYPE,
CONF_USE_WEBHOOK,
DEFAULT_SCAN_INTERVAL,
DOCS_URL,
PLACEHOLDER_DEVICE_NAME,
PLACEHOLDER_DEVICE_TYPE,
PLACEHOLDER_DOCS_URL,
PLACEHOLDER_WEBHOOK_URL,
)
from .const import DOMAIN # pylint:disable=unused-import
_LOGGER = logging.getLogger(__package__)
class PlaatoConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
"""Handles a Plaato config flow."""
VERSION = 2
CONNECTION_CLASS = config_entries.CONN_CLASS_LOCAL_POLL
def __init__(self):
"""Initialize."""
self._init_info = {}
async def async_step_user(self, user_input=None):
"""Handle user step."""
if user_input is not None:
self._init_info[CONF_DEVICE_TYPE] = PlaatoDeviceType(
user_input[CONF_DEVICE_TYPE]
)
self._init_info[CONF_DEVICE_NAME] = user_input[CONF_DEVICE_NAME]
return await self.async_step_api_method()
return self.async_show_form(
step_id="user",
data_schema=vol.Schema(
{
vol.Required(
CONF_DEVICE_NAME,
default=self._init_info.get(CONF_DEVICE_NAME, None),
): str,
vol.Required(
CONF_DEVICE_TYPE,
default=self._init_info.get(CONF_DEVICE_TYPE, None),
): vol.In(list(PlaatoDeviceType)),
}
),
)
async def async_step_api_method(self, user_input=None):
"""Handle device type step."""
device_type = self._init_info[CONF_DEVICE_TYPE]
if user_input is not None:
token = user_input.get(CONF_TOKEN, None)
use_webhook = user_input.get(CONF_USE_WEBHOOK, False)
if not token and not use_webhook:
errors = {"base": PlaatoConfigFlow._get_error(device_type)}
return await self._show_api_method_form(device_type, errors)
self._init_info[CONF_USE_WEBHOOK] = use_webhook
self._init_info[CONF_TOKEN] = token
return await self.async_step_webhook()
return await self._show_api_method_form(device_type)
async def async_step_webhook(self, user_input=None):
"""Validate config step."""
use_webhook = self._init_info[CONF_USE_WEBHOOK]
if use_webhook and user_input is None:
webhook_id, webhook_url, cloudhook = await self._get_webhook_id()
self._init_info[CONF_WEBHOOK_ID] = webhook_id
self._init_info[CONF_CLOUDHOOK] = cloudhook
return self.async_show_form(
step_id="webhook",
description_placeholders={
PLACEHOLDER_WEBHOOK_URL: webhook_url,
PLACEHOLDER_DOCS_URL: DOCS_URL,
},
)
return await self._async_create_entry()
async def _async_create_entry(self):
"""Create the entry step."""
webhook_id = self._init_info.get(CONF_WEBHOOK_ID, None)
auth_token = self._init_info[CONF_TOKEN]
device_name = self._init_info[CONF_DEVICE_NAME]
device_type = self._init_info[CONF_DEVICE_TYPE]
unique_id = auth_token if auth_token else webhook_id
await self.async_set_unique_id(unique_id)
self._abort_if_unique_id_configured()
return self.async_create_entry(
title=device_type.name,
data=self._init_info,
description_placeholders={
PLACEHOLDER_DEVICE_TYPE: device_type.name,
PLACEHOLDER_DEVICE_NAME: device_name,
},
)
async def _show_api_method_form(
self, device_type: PlaatoDeviceType, errors: dict = None
):
data_scheme = vol.Schema({vol.Optional(CONF_TOKEN, default=""): str})
if device_type == PlaatoDeviceType.Airlock:
data_scheme = data_scheme.extend(
{vol.Optional(CONF_USE_WEBHOOK, default=False): bool}
)
return self.async_show_form(
step_id="api_method",
data_schema=data_scheme,
errors=errors,
description_placeholders={PLACEHOLDER_DEVICE_TYPE: device_type.name},
)
async def _get_webhook_id(self):
"""Generate webhook ID."""
webhook_id = self.hass.components.webhook.async_generate_id()
if self.hass.components.cloud.async_active_subscription():
webhook_url = await self.hass.components.cloud.async_create_cloudhook(
webhook_id
)
cloudhook = True
else:
webhook_url = self.hass.components.webhook.async_generate_url(webhook_id)
cloudhook = False
return webhook_id, webhook_url, cloudhook
@staticmethod
def _get_error(device_type: PlaatoDeviceType):
if device_type == PlaatoDeviceType.Airlock:
return "no_api_method"
return "no_auth_token"
@staticmethod
@callback
def async_get_options_flow(config_entry):
"""Get the options flow for this handler."""
return PlaatoOptionsFlowHandler(config_entry)
class PlaatoOptionsFlowHandler(config_entries.OptionsFlow):
"""Handle Plaato options."""
def __init__(self, config_entry: ConfigEntry):
"""Initialize domain options flow."""
super().__init__()
self._config_entry = config_entry
async def async_step_init(self, user_input=None):
"""Manage the options."""
use_webhook = self._config_entry.data.get(CONF_USE_WEBHOOK, False)
if use_webhook:
return await self.async_step_webhook()
return await self.async_step_user()
async def async_step_user(self, user_input=None):
"""Manage the options."""
if user_input is not None:
return self.async_create_entry(title="", data=user_input)
return self.async_show_form(
step_id="user",
data_schema=vol.Schema(
{
vol.Optional(
CONF_SCAN_INTERVAL,
default=self._config_entry.options.get(
CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL
),
): cv.positive_int
}
),
)
async def async_step_webhook(self, user_input=None):
"""Manage the options for webhook device."""
if user_input is not None:
return self.async_create_entry(title="", data=user_input)
webhook_id = self._config_entry.data.get(CONF_WEBHOOK_ID, None)
webhook_url = (
""
if webhook_id is None
else self.hass.components.webhook.async_generate_url(webhook_id)
)
return self.async_show_form(
step_id="webhook",
description_placeholders={PLACEHOLDER_WEBHOOK_URL: webhook_url},
) )

View File

@ -1,3 +1,27 @@
"""Const for GPSLogger.""" """Const for Plaato."""
from datetime import timedelta
DOMAIN = "plaato" DOMAIN = "plaato"
PLAATO_DEVICE_SENSORS = "sensors"
PLAATO_DEVICE_ATTRS = "attrs"
SENSOR_SIGNAL = f"{DOMAIN}_%s_%s"
CONF_USE_WEBHOOK = "use_webhook"
CONF_DEVICE_TYPE = "device_type"
CONF_DEVICE_NAME = "device_name"
CONF_CLOUDHOOK = "cloudhook"
PLACEHOLDER_WEBHOOK_URL = "webhook_url"
PLACEHOLDER_DOCS_URL = "docs_url"
PLACEHOLDER_DEVICE_TYPE = "device_type"
PLACEHOLDER_DEVICE_NAME = "device_name"
DOCS_URL = "https://www.home-assistant.io/integrations/plaato/"
PLATFORMS = ["sensor", "binary_sensor"]
SENSOR_DATA = "sensor_data"
COORDINATOR = "coordinator"
DEVICE = "device"
DEVICE_NAME = "device_name"
DEVICE_TYPE = "device_type"
DEVICE_ID = "device_id"
UNDO_UPDATE_LISTENER = "undo_update_listener"
DEFAULT_SCAN_INTERVAL = 5
MIN_UPDATE_INTERVAL = timedelta(minutes=1)

View File

@ -0,0 +1,103 @@
"""PlaatoEntity class."""
from pyplaato.models.device import PlaatoDevice
from homeassistant.helpers import entity
from .const import (
DEVICE,
DEVICE_ID,
DEVICE_NAME,
DEVICE_TYPE,
DOMAIN,
SENSOR_DATA,
SENSOR_SIGNAL,
)
class PlaatoEntity(entity.Entity):
"""Representation of a Plaato Entity."""
def __init__(self, data, sensor_type, coordinator=None):
"""Initialize the sensor."""
self._coordinator = coordinator
self._entry_data = data
self._sensor_type = sensor_type
self._device_id = data[DEVICE][DEVICE_ID]
self._device_type = data[DEVICE][DEVICE_TYPE]
self._device_name = data[DEVICE][DEVICE_NAME]
self._state = 0
@property
def _attributes(self) -> dict:
return PlaatoEntity._to_snake_case(self._sensor_data.attributes)
@property
def _sensor_name(self) -> str:
return self._sensor_data.get_sensor_name(self._sensor_type)
@property
def _sensor_data(self) -> PlaatoDevice:
if self._coordinator:
return self._coordinator.data
return self._entry_data[SENSOR_DATA]
@property
def name(self):
"""Return the name of the sensor."""
return f"{DOMAIN} {self._device_type} {self._device_name} {self._sensor_name}".title()
@property
def unique_id(self):
"""Return the unique ID of this sensor."""
return f"{self._device_id}_{self._sensor_type}"
@property
def device_info(self):
"""Get device info."""
device_info = {
"identifiers": {(DOMAIN, self._device_id)},
"name": self._device_name,
"manufacturer": "Plaato",
"model": self._device_type,
}
if self._sensor_data.firmware_version != "":
device_info["sw_version"] = self._sensor_data.firmware_version
return device_info
@property
def device_state_attributes(self):
"""Return the state attributes of the monitored installation."""
if self._attributes is not None:
return self._attributes
@property
def available(self):
"""Return if sensor is available."""
if self._coordinator is not None:
return self._coordinator.last_update_success
return True
@property
def should_poll(self):
"""Return the polling state."""
return False
async def async_added_to_hass(self):
"""When entity is added to hass."""
if self._coordinator is not None:
self.async_on_remove(
self._coordinator.async_add_listener(self.async_write_ha_state)
)
else:
self.async_on_remove(
self.hass.helpers.dispatcher.async_dispatcher_connect(
SENSOR_SIGNAL % (self._device_id, self._sensor_type),
self.async_write_ha_state,
)
)
@staticmethod
def _to_snake_case(dictionary: dict):
return {k.lower().replace(" ", "_"): v for k, v in dictionary.items()}

View File

@ -1,8 +1,10 @@
{ {
"domain": "plaato", "domain": "plaato",
"name": "Plaato Airlock", "name": "Plaato",
"config_flow": true, "config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/plaato", "documentation": "https://www.home-assistant.io/integrations/plaato",
"dependencies": ["webhook"], "dependencies": ["webhook"],
"codeowners": ["@JohNan"] "after_dependencies": ["cloud"],
"codeowners": ["@JohNan"],
"requirements": ["pyplaato==0.0.15"]
} }

View File

@ -1,28 +1,29 @@
"""Support for Plaato Airlock sensors.""" """Support for Plaato Airlock sensors."""
import logging import logging
from typing import Optional
from homeassistant.const import PERCENTAGE from pyplaato.models.device import PlaatoDevice
from pyplaato.plaato import PlaatoKeg
from homeassistant.components.sensor import DEVICE_CLASS_TEMPERATURE
from homeassistant.helpers.dispatcher import ( from homeassistant.helpers.dispatcher import (
async_dispatcher_connect, async_dispatcher_connect,
async_dispatcher_send, async_dispatcher_send,
) )
from homeassistant.helpers.entity import Entity
from . import ( from . import ATTR_TEMP, SENSOR_UPDATE
ATTR_ABV, from ...core import callback
ATTR_BATCH_VOLUME, from .const import (
ATTR_BPM, CONF_USE_WEBHOOK,
ATTR_CO2_VOLUME, COORDINATOR,
ATTR_TEMP, DEVICE,
ATTR_TEMP_UNIT, DEVICE_ID,
ATTR_VOLUME_UNIT, DOMAIN,
DOMAIN as PLAATO_DOMAIN, SENSOR_DATA,
PLAATO_DEVICE_ATTRS, SENSOR_SIGNAL,
PLAATO_DEVICE_SENSORS,
SENSOR_DATA_KEY,
SENSOR_UPDATE,
) )
from .entity import PlaatoEntity
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
@ -31,134 +32,58 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info=
"""Set up the Plaato sensor.""" """Set up the Plaato sensor."""
async def async_setup_entry(hass, config_entry, async_add_entities): async def async_setup_entry(hass, entry, async_add_entities):
"""Set up Plaato from a config entry.""" """Set up Plaato from a config entry."""
devices = {} entry_data = hass.data[DOMAIN][entry.entry_id]
def get_device(device_id): @callback
"""Get a device.""" async def _async_update_from_webhook(device_id, sensor_data: PlaatoDevice):
return hass.data[PLAATO_DOMAIN].get(device_id, False)
def get_device_sensors(device_id):
"""Get device sensors."""
return hass.data[PLAATO_DOMAIN].get(device_id).get(PLAATO_DEVICE_SENSORS)
async def _update_sensor(device_id):
"""Update/Create the sensors.""" """Update/Create the sensors."""
if device_id not in devices and get_device(device_id): entry_data[SENSOR_DATA] = sensor_data
entities = []
sensors = get_device_sensors(device_id)
for sensor_type in sensors: if device_id != entry_data[DEVICE][DEVICE_ID]:
entities.append(PlaatoSensor(device_id, sensor_type)) entry_data[DEVICE][DEVICE_ID] = device_id
async_add_entities(
devices[device_id] = entities [
PlaatoSensor(entry_data, sensor_type)
async_add_entities(entities, True) for sensor_type in sensor_data.sensors
]
)
else: else:
for entity in devices[device_id]: for sensor_type in sensor_data.sensors:
async_dispatcher_send(hass, f"{PLAATO_DOMAIN}_{entity.unique_id}") async_dispatcher_send(hass, SENSOR_SIGNAL % (device_id, sensor_type))
hass.data[SENSOR_DATA_KEY] = async_dispatcher_connect( if entry.data[CONF_USE_WEBHOOK]:
hass, SENSOR_UPDATE, _update_sensor async_dispatcher_connect(hass, SENSOR_UPDATE, _async_update_from_webhook)
else:
coordinator = entry_data[COORDINATOR]
async_add_entities(
PlaatoSensor(entry_data, sensor_type, coordinator)
for sensor_type in coordinator.data.sensors.keys()
) )
return True return True
class PlaatoSensor(Entity): class PlaatoSensor(PlaatoEntity):
"""Representation of a Sensor.""" """Representation of a Plaato Sensor."""
def __init__(self, device_id, sensor_type):
"""Initialize the sensor."""
self._device_id = device_id
self._type = sensor_type
self._state = 0
self._name = f"{device_id} {sensor_type}"
self._attributes = None
@property @property
def name(self): def device_class(self) -> Optional[str]:
"""Return the name of the sensor.""" """Return the class of this device, from component DEVICE_CLASSES."""
return f"{PLAATO_DOMAIN} {self._name}" if self._coordinator is not None:
if self._sensor_type == PlaatoKeg.Pins.TEMPERATURE:
@property return DEVICE_CLASS_TEMPERATURE
def unique_id(self): if self._sensor_type == ATTR_TEMP:
"""Return the unique ID of this sensor.""" return DEVICE_CLASS_TEMPERATURE
return f"{self._device_id}_{self._type}" return None
@property
def device_info(self):
"""Get device info."""
return {
"identifiers": {(PLAATO_DOMAIN, self._device_id)},
"name": self._device_id,
"manufacturer": "Plaato",
"model": "Airlock",
}
def get_sensors(self):
"""Get device sensors."""
return (
self.hass.data[PLAATO_DOMAIN]
.get(self._device_id)
.get(PLAATO_DEVICE_SENSORS, False)
)
def get_sensors_unit_of_measurement(self, sensor_type):
"""Get unit of measurement for sensor of type."""
return (
self.hass.data[PLAATO_DOMAIN]
.get(self._device_id)
.get(PLAATO_DEVICE_ATTRS, [])
.get(sensor_type, "")
)
@property @property
def state(self): def state(self):
"""Return the state of the sensor.""" """Return the state of the sensor."""
sensors = self.get_sensors() return self._sensor_data.sensors.get(self._sensor_type)
if sensors is False:
_LOGGER.debug("Device with name %s has no sensors", self.name)
return 0
if self._type == ATTR_ABV:
return round(sensors.get(self._type), 2)
if self._type == ATTR_TEMP:
return round(sensors.get(self._type), 1)
if self._type == ATTR_CO2_VOLUME:
return round(sensors.get(self._type), 2)
return sensors.get(self._type)
@property
def device_state_attributes(self):
"""Return the state attributes of the monitored installation."""
if self._attributes is not None:
return self._attributes
@property @property
def unit_of_measurement(self): def unit_of_measurement(self):
"""Return the unit of measurement.""" """Return the unit of measurement."""
if self._type == ATTR_TEMP: return self._sensor_data.get_unit_of_measurement(self._sensor_type)
return self.get_sensors_unit_of_measurement(ATTR_TEMP_UNIT)
if self._type == ATTR_BATCH_VOLUME or self._type == ATTR_CO2_VOLUME:
return self.get_sensors_unit_of_measurement(ATTR_VOLUME_UNIT)
if self._type == ATTR_BPM:
return "bpm"
if self._type == ATTR_ABV:
return PERCENTAGE
return ""
@property
def should_poll(self):
"""Return the polling state."""
return False
async def async_added_to_hass(self):
"""Register callbacks."""
self.async_on_remove(
self.hass.helpers.dispatcher.async_dispatcher_connect(
f"{PLAATO_DOMAIN}_{self.unique_id}", self.async_write_ha_state
)
)

View File

@ -2,16 +2,53 @@
"config": { "config": {
"step": { "step": {
"user": { "user": {
"title": "Set up the Plaato Webhook", "title": "Set up the Plaato devices",
"description": "[%key:common::config_flow::description::confirm_setup%]" "description": "[%key:common::config_flow::description::confirm_setup%]",
"data": {
"device_name": "Name your device",
"device_type": "Type of Plaato device"
} }
}, },
"api_method": {
"title": "Select API method",
"description": "To be able to query the API an `auth_token` is required which can be obtained by following [these](https://plaato.zendesk.com/hc/en-us/articles/360003234717-Auth-token) instructions\n\n Selected device: **{device_type}** \n\nIf you rather use the built in webhook method (Airlock only) please check the box below and leave Auth Token blank",
"data": {
"use_webhook": "Use webhook",
"token": "Paste Auth Token here"
}
},
"webhook": {
"title": "Webhook to use",
"description": "To send events to Home Assistant, you will need to setup the webhook feature in Plaato Airlock.\n\nFill in the following info:\n\n- URL: `{webhook_url}`\n- Method: POST\n\nSee [the documentation]({docs_url}) for further details."
}
},
"error": {
"invalid_webhook_device": "You have selected a device that doesn't not support sending data to a webhook. It is only available for the Airlock",
"no_auth_token": "You need to add an auth token",
"no_api_method": "You need to add an auth token or select webhook"
},
"abort": { "abort": {
"single_instance_allowed": "[%key:common::config_flow::abort::single_instance_allowed%]", "single_instance_allowed": "[%key:common::config_flow::abort::single_instance_allowed%]",
"webhook_not_internet_accessible": "[%key:common::config_flow::abort::webhook_not_internet_accessible%]" "webhook_not_internet_accessible": "[%key:common::config_flow::abort::webhook_not_internet_accessible%]",
"already_configured": "[%key:common::config_flow::abort::already_configured_account%]"
}, },
"create_entry": { "create_entry": {
"default": "To send events to Home Assistant, you will need to setup the webhook feature in Plaato Airlock.\n\nFill in the following info:\n\n- URL: `{webhook_url}`\n- Method: POST\n\nSee [the documentation]({docs_url}) for further details." "default": "Your Plaato {device_type} with name **{device_name}** was successfully setup!"
}
},
"options": {
"step": {
"webhook": {
"title": "Options for Plaato Airlock",
"description": "Webhook info:\n\n- URL: `{webhook_url}`\n- Method: POST\n\n"
},
"user": {
"title": "Options for Plaato",
"description": "Set the update interval (minutes)",
"data": {
"update_interval": "Update interval (minutes)"
}
}
} }
} }
} }

View File

@ -1,16 +1,53 @@
{ {
"config": { "config": {
"abort": { "abort": {
"already_configured": "Account is already configured",
"single_instance_allowed": "Already configured. Only a single configuration possible.", "single_instance_allowed": "Already configured. Only a single configuration possible.",
"webhook_not_internet_accessible": "Your Home Assistant instance needs to be accessible from the internet to receive webhook messages." "webhook_not_internet_accessible": "Your Home Assistant instance needs to be accessible from the internet to receive webhook messages."
}, },
"create_entry": { "create_entry": {
"default": "To send events to Home Assistant, you will need to setup the webhook feature in Plaato Airlock.\n\nFill in the following info:\n\n- URL: `{webhook_url}`\n- Method: POST\n\nSee [the documentation]({docs_url}) for further details." "default": "Your Plaato {device_type} with name **{device_name}** was successfully setup!"
},
"error": {
"invalid_webhook_device": "You have selected a device that doesn't not support sending data to a webhook. It is only available for the Airlock",
"no_api_method": "You need to add an auth token or select webhook",
"no_auth_token": "You need to add an auth token"
}, },
"step": { "step": {
"api_method": {
"data": {
"token": "Paste Auth Token here",
"use_webhook": "Use webhook"
},
"description": "To be able to query the API an `auth_token` is required which can be obtained by following [these](https://plaato.zendesk.com/hc/en-us/articles/360003234717-Auth-token) instructions\n\n Selected device: **{device_type}** \n\nIf you rather use the built in webhook method (Airlock only) please check the box below and leave Auth Token blank",
"title": "Select API method"
},
"user": { "user": {
"data": {
"device_name": "Name your device",
"device_type": "Type of Plaato device"
},
"description": "Do you want to start set up?", "description": "Do you want to start set up?",
"title": "Set up the Plaato Webhook" "title": "Set up the Plaato devices"
},
"webhook": {
"description": "To send events to Home Assistant, you will need to setup the webhook feature in Plaato Airlock.\n\nFill in the following info:\n\n- URL: `{webhook_url}`\n- Method: POST\n\nSee [the documentation]({docs_url}) for further details.",
"title": "Webhook to use"
}
}
},
"options": {
"step": {
"user": {
"data": {
"update_interval": "Update interval (minutes)"
},
"description": "Set the update interval (minutes)",
"title": "Options for Plaato"
},
"webhook": {
"description": "Webhook info:\n\n- URL: `{webhook_url}`\n- Method: POST\n\n",
"title": "Options for Plaato Airlock"
} }
} }
} }

View File

@ -1615,6 +1615,9 @@ pypck==0.7.9
# homeassistant.components.pjlink # homeassistant.components.pjlink
pypjlink2==1.2.1 pypjlink2==1.2.1
# homeassistant.components.plaato
pyplaato==0.0.15
# homeassistant.components.point # homeassistant.components.point
pypoint==2.0.0 pypoint==2.0.0

View File

@ -842,6 +842,9 @@ pyowm==3.1.1
# homeassistant.components.onewire # homeassistant.components.onewire
pyownet==0.10.0.post1 pyownet==0.10.0.post1
# homeassistant.components.plaato
pyplaato==0.0.15
# homeassistant.components.point # homeassistant.components.point
pypoint==2.0.0 pypoint==2.0.0

View File

@ -0,0 +1 @@
"""Tests for the Plaato integration."""

View File

@ -0,0 +1,286 @@
"""Test the Plaato config flow."""
from unittest.mock import patch
from pyplaato.models.device import PlaatoDeviceType
import pytest
from homeassistant import config_entries, data_entry_flow, setup
from homeassistant.components.plaato.const import (
CONF_DEVICE_NAME,
CONF_DEVICE_TYPE,
CONF_USE_WEBHOOK,
DOMAIN,
)
from homeassistant.const import CONF_SCAN_INTERVAL, CONF_TOKEN, CONF_WEBHOOK_ID
from homeassistant.data_entry_flow import RESULT_TYPE_CREATE_ENTRY, RESULT_TYPE_FORM
from tests.common import MockConfigEntry
BASE_URL = "http://example.com"
WEBHOOK_ID = "webhook_id"
UNIQUE_ID = "plaato_unique_id"
@pytest.fixture(name="webhook_id")
def mock_webhook_id():
"""Mock webhook_id."""
with patch(
"homeassistant.components.webhook.async_generate_id", return_value=WEBHOOK_ID
), patch(
"homeassistant.components.webhook.async_generate_url", return_value="hook_id"
):
yield
async def test_show_config_form(hass):
"""Test show configuration 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"] == "form"
assert result["step_id"] == "user"
async def test_show_config_form_device_type_airlock(hass):
"""Test show configuration form."""
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": config_entries.SOURCE_USER},
data={
CONF_DEVICE_TYPE: PlaatoDeviceType.Airlock,
CONF_DEVICE_NAME: "device_name",
},
)
assert result["type"] == RESULT_TYPE_FORM
assert result["step_id"] == "api_method"
assert result["data_schema"].schema.get(CONF_TOKEN) == str
assert result["data_schema"].schema.get(CONF_USE_WEBHOOK) == bool
async def test_show_config_form_device_type_keg(hass):
"""Test show configuration form."""
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": config_entries.SOURCE_USER},
data={CONF_DEVICE_TYPE: PlaatoDeviceType.Keg, CONF_DEVICE_NAME: "device_name"},
)
assert result["type"] == RESULT_TYPE_FORM
assert result["step_id"] == "api_method"
assert result["data_schema"].schema.get(CONF_TOKEN) == str
assert result["data_schema"].schema.get(CONF_USE_WEBHOOK) is None
async def test_show_config_form_validate_webhook(hass, webhook_id):
"""Test show configuration form."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
assert result["type"] == RESULT_TYPE_FORM
assert result["step_id"] == "user"
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
user_input={
CONF_DEVICE_TYPE: PlaatoDeviceType.Airlock,
CONF_DEVICE_NAME: "device_name",
},
)
assert result["type"] == RESULT_TYPE_FORM
assert result["step_id"] == "api_method"
async def return_async_value(val):
return val
hass.config.components.add("cloud")
with patch(
"homeassistant.components.cloud.async_active_subscription", return_value=True
), patch(
"homeassistant.components.cloud.async_create_cloudhook",
return_value=return_async_value("https://hooks.nabu.casa/ABCD"),
):
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
user_input={
CONF_TOKEN: "",
CONF_USE_WEBHOOK: True,
},
)
assert result["type"] == RESULT_TYPE_FORM
assert result["step_id"] == "webhook"
async def test_show_config_form_validate_token(hass):
"""Test show configuration form."""
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_DEVICE_TYPE: PlaatoDeviceType.Keg,
CONF_DEVICE_NAME: "device_name",
},
)
assert result["type"] == RESULT_TYPE_FORM
assert result["step_id"] == "api_method"
with patch("homeassistant.components.plaato.async_setup_entry", return_value=True):
result = await hass.config_entries.flow.async_configure(
result["flow_id"], user_input={CONF_TOKEN: "valid_token"}
)
assert result["type"] == RESULT_TYPE_CREATE_ENTRY
assert result["title"] == PlaatoDeviceType.Keg.name
assert result["data"] == {
CONF_USE_WEBHOOK: False,
CONF_TOKEN: "valid_token",
CONF_DEVICE_TYPE: PlaatoDeviceType.Keg,
CONF_DEVICE_NAME: "device_name",
}
async def test_show_config_form_no_cloud_webhook(hass, webhook_id):
"""Test show configuration form."""
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_DEVICE_TYPE: PlaatoDeviceType.Airlock,
CONF_DEVICE_NAME: "device_name",
},
)
assert result["type"] == RESULT_TYPE_FORM
assert result["step_id"] == "api_method"
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
user_input={
CONF_USE_WEBHOOK: True,
CONF_TOKEN: "",
},
)
assert result["type"] == RESULT_TYPE_FORM
assert result["step_id"] == "webhook"
assert result["errors"] is None
async def test_show_config_form_api_method_no_auth_token(hass, webhook_id):
"""Test show configuration form."""
# Using Keg
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_DEVICE_TYPE: PlaatoDeviceType.Keg,
CONF_DEVICE_NAME: "device_name",
},
)
assert result["type"] == RESULT_TYPE_FORM
assert result["step_id"] == "api_method"
result = await hass.config_entries.flow.async_configure(
result["flow_id"], user_input={CONF_TOKEN: ""}
)
assert result["type"] == RESULT_TYPE_FORM
assert result["step_id"] == "api_method"
assert len(result["errors"]) == 1
assert result["errors"]["base"] == "no_auth_token"
# Using Airlock
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_DEVICE_TYPE: PlaatoDeviceType.Airlock,
CONF_DEVICE_NAME: "device_name",
},
)
assert result["type"] == RESULT_TYPE_FORM
assert result["step_id"] == "api_method"
result = await hass.config_entries.flow.async_configure(
result["flow_id"], user_input={CONF_TOKEN: ""}
)
assert result["type"] == RESULT_TYPE_FORM
assert result["step_id"] == "api_method"
assert len(result["errors"]) == 1
assert result["errors"]["base"] == "no_api_method"
async def test_options(hass):
"""Test updating options."""
config_entry = MockConfigEntry(
domain=DOMAIN,
title="NAME",
data={},
options={CONF_SCAN_INTERVAL: 5},
)
config_entry.add_to_hass(hass)
with patch("homeassistant.components.plaato.async_setup_entry", return_value=True):
result = await hass.config_entries.options.async_init(config_entry.entry_id)
assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
assert result["step_id"] == "user"
result = await hass.config_entries.options.async_configure(
result["flow_id"],
user_input={CONF_SCAN_INTERVAL: 10},
)
assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
assert result["data"][CONF_SCAN_INTERVAL] == 10
async def test_options_webhook(hass, webhook_id):
"""Test updating options."""
config_entry = MockConfigEntry(
domain=DOMAIN,
title="NAME",
data={CONF_USE_WEBHOOK: True, CONF_WEBHOOK_ID: None},
options={CONF_SCAN_INTERVAL: 5},
)
config_entry.add_to_hass(hass)
with patch("homeassistant.components.plaato.async_setup_entry", return_value=True):
result = await hass.config_entries.options.async_init(config_entry.entry_id)
assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
assert result["step_id"] == "webhook"
assert result["description_placeholders"] == {"webhook_url": ""}
result = await hass.config_entries.options.async_configure(
result["flow_id"],
user_input={CONF_WEBHOOK_ID: WEBHOOK_ID},
)
assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
assert result["data"][CONF_WEBHOOK_ID] == CONF_WEBHOOK_ID