diff --git a/.coveragerc b/.coveragerc index 0609051f19635be562b6c0d44178dc965a5fec3d..3a7f33f6050b91e8b846be9f0acab7c331e409cf 100644 --- a/.coveragerc +++ b/.coveragerc @@ -702,7 +702,11 @@ omit = homeassistant/components/ping/device_tracker.py homeassistant/components/pioneer/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/plum_lightpad/light.py homeassistant/components/pocketcasts/sensor.py diff --git a/homeassistant/components/plaato/__init__.py b/homeassistant/components/plaato/__init__.py index b365c7e0081bed8994b1d2fbe4a9469276471ba6..2cf97d5fd9a5a5b2122ffeded0a182dfcf79b063 100644 --- a/homeassistant/components/plaato/__init__.py +++ b/homeassistant/components/plaato/__init__.py @@ -1,11 +1,34 @@ -"""Support for Plaato Airlock.""" +"""Support for Plaato devices.""" + +import asyncio +from datetime import timedelta import logging 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 from homeassistant.components.sensor import DOMAIN as SENSOR +from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( + CONF_SCAN_INTERVAL, + CONF_TOKEN, CONF_WEBHOOK_ID, HTTP_OK, TEMP_CELSIUS, @@ -13,31 +36,33 @@ from homeassistant.const import ( VOLUME_GALLONS, 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 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__) 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_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.""" + hass.data.setdefault(DOMAIN, {}) return True -async def async_setup_entry(hass, entry): +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry): """Configure based on config entry.""" - if DOMAIN not in hass.data: - hass.data[DOMAIN] = {} - webhook_id = entry.data[CONF_WEBHOOK_ID] - hass.components.webhook.async_register(DOMAIN, "Plaato", webhook_id, handle_webhook) + use_webhook = entry.data[CONF_USE_WEBHOOK] + + if use_webhook: + async_setup_webhook(hass, entry) + else: + await async_setup_coordinator(hass, entry) - hass.async_create_task(hass.config_entries.async_forward_entry_setup(entry, SENSOR)) + 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 -async def async_unload_entry(hass, entry): +@callback +def async_setup_webhook(hass: HomeAssistant, entry: ConfigEntry): + """Init webhook based on config entry.""" + webhook_id = entry.data[CONF_WEBHOOK_ID] + device_name = entry.data[CONF_DEVICE_NAME] + + _set_entry_data(entry, hass) + + hass.components.webhook.async_register( + DOMAIN, f"{DOMAIN}.{device_name}", webhook_id, handle_webhook + ) + + +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.""" - hass.components.webhook.async_unregister(entry.data[CONF_WEBHOOK_ID]) - hass.data[SENSOR_DATA_KEY]() + use_webhook = entry.data[CONF_USE_WEBHOOK] + hass.data[DOMAIN][entry.entry_id][UNDO_UPDATE_LISTENER]() - await hass.config_entries.async_forward_entry_unload(entry, SENSOR) - return True + if use_webhook: + 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): @@ -96,31 +214,9 @@ async def handle_webhook(hass, webhook_id, request): return device_id = _device_id(data) + sensor_data = PlaatoAirlock.from_web_hook(data) - attrs = { - 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) + async_dispatcher_send(hass, SENSOR_UPDATE, *(device_id, sensor_data)) 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): """Return name of device sensor.""" 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 diff --git a/homeassistant/components/plaato/binary_sensor.py b/homeassistant/components/plaato/binary_sensor.py new file mode 100644 index 0000000000000000000000000000000000000000..0ee61b7668bd0a0ebcd8bc042c1c6c637d7aefba --- /dev/null +++ b/homeassistant/components/plaato/binary_sensor.py @@ -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 diff --git a/homeassistant/components/plaato/config_flow.py b/homeassistant/components/plaato/config_flow.py index 3c616c822fb984cfd4e79ed2f3347c6a6be1844d..290776f47c116438a7ddf8dd8262725d7a39e8fb 100644 --- a/homeassistant/components/plaato/config_flow.py +++ b/homeassistant/components/plaato/config_flow.py @@ -1,10 +1,223 @@ -"""Config flow for GPSLogger.""" -from homeassistant.helpers import config_entry_flow +"""Config flow for Plaato.""" +import logging -from .const import DOMAIN +from pyplaato.plaato import PlaatoDeviceType +import voluptuous as vol -config_entry_flow.register_webhook_flow( - DOMAIN, - "Webhook", - {"docs_url": "https://www.home-assistant.io/integrations/plaato/"}, +from homeassistant import config_entries +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_SCAN_INTERVAL, CONF_TOKEN, CONF_WEBHOOK_ID +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}, + ) diff --git a/homeassistant/components/plaato/const.py b/homeassistant/components/plaato/const.py index cbe8fcd2b6dc43b6f63a9b8035ab292e24e91d01..f50eaaac0ed8863128dd29220eeb4277e430f2ca 100644 --- a/homeassistant/components/plaato/const.py +++ b/homeassistant/components/plaato/const.py @@ -1,3 +1,27 @@ -"""Const for GPSLogger.""" +"""Const for Plaato.""" +from datetime import timedelta 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) diff --git a/homeassistant/components/plaato/entity.py b/homeassistant/components/plaato/entity.py new file mode 100644 index 0000000000000000000000000000000000000000..6f72c3419a4626d92a770c997c3953bcde8de8c4 --- /dev/null +++ b/homeassistant/components/plaato/entity.py @@ -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()} diff --git a/homeassistant/components/plaato/manifest.json b/homeassistant/components/plaato/manifest.json index 29e104b13ed3942935a336cc6c72068a76f3356c..e3291e5a229d243613be51174a1151229ed697b1 100644 --- a/homeassistant/components/plaato/manifest.json +++ b/homeassistant/components/plaato/manifest.json @@ -1,8 +1,10 @@ { "domain": "plaato", - "name": "Plaato Airlock", + "name": "Plaato", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/plaato", "dependencies": ["webhook"], - "codeowners": ["@JohNan"] + "after_dependencies": ["cloud"], + "codeowners": ["@JohNan"], + "requirements": ["pyplaato==0.0.15"] } diff --git a/homeassistant/components/plaato/sensor.py b/homeassistant/components/plaato/sensor.py index 3f8034698fd8439f9e4ab34b540d27b3fd780e70..3f5e467f504a2d52c6e70b89f5dd5d698fe8a3b0 100644 --- a/homeassistant/components/plaato/sensor.py +++ b/homeassistant/components/plaato/sensor.py @@ -1,28 +1,29 @@ """Support for Plaato Airlock sensors.""" 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 ( async_dispatcher_connect, async_dispatcher_send, ) -from homeassistant.helpers.entity import Entity - -from . import ( - ATTR_ABV, - ATTR_BATCH_VOLUME, - ATTR_BPM, - ATTR_CO2_VOLUME, - ATTR_TEMP, - ATTR_TEMP_UNIT, - ATTR_VOLUME_UNIT, - DOMAIN as PLAATO_DOMAIN, - PLAATO_DEVICE_ATTRS, - PLAATO_DEVICE_SENSORS, - SENSOR_DATA_KEY, - SENSOR_UPDATE, + +from . import ATTR_TEMP, SENSOR_UPDATE +from ...core import callback +from .const import ( + CONF_USE_WEBHOOK, + COORDINATOR, + DEVICE, + DEVICE_ID, + DOMAIN, + SENSOR_DATA, + SENSOR_SIGNAL, ) +from .entity import PlaatoEntity _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.""" -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.""" - devices = {} - - def get_device(device_id): - """Get a device.""" - 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) + entry_data = hass.data[DOMAIN][entry.entry_id] - async def _update_sensor(device_id): + @callback + async def _async_update_from_webhook(device_id, sensor_data: PlaatoDevice): """Update/Create the sensors.""" - if device_id not in devices and get_device(device_id): - entities = [] - sensors = get_device_sensors(device_id) - - for sensor_type in sensors: - entities.append(PlaatoSensor(device_id, sensor_type)) - - devices[device_id] = entities - - async_add_entities(entities, True) + entry_data[SENSOR_DATA] = sensor_data + + if device_id != entry_data[DEVICE][DEVICE_ID]: + entry_data[DEVICE][DEVICE_ID] = device_id + async_add_entities( + [ + PlaatoSensor(entry_data, sensor_type) + for sensor_type in sensor_data.sensors + ] + ) else: - for entity in devices[device_id]: - async_dispatcher_send(hass, f"{PLAATO_DOMAIN}_{entity.unique_id}") - - hass.data[SENSOR_DATA_KEY] = async_dispatcher_connect( - hass, SENSOR_UPDATE, _update_sensor - ) + for sensor_type in sensor_data.sensors: + async_dispatcher_send(hass, SENSOR_SIGNAL % (device_id, sensor_type)) + + if entry.data[CONF_USE_WEBHOOK]: + 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 -class PlaatoSensor(Entity): - """Representation of a 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 - def name(self): - """Return the name of the sensor.""" - return f"{PLAATO_DOMAIN} {self._name}" +class PlaatoSensor(PlaatoEntity): + """Representation of a Plaato Sensor.""" @property - def unique_id(self): - """Return the unique ID of this sensor.""" - return f"{self._device_id}_{self._type}" - - @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, "") - ) + def device_class(self) -> Optional[str]: + """Return the class of this device, from component DEVICE_CLASSES.""" + if self._coordinator is not None: + if self._sensor_type == PlaatoKeg.Pins.TEMPERATURE: + return DEVICE_CLASS_TEMPERATURE + if self._sensor_type == ATTR_TEMP: + return DEVICE_CLASS_TEMPERATURE + return None @property def state(self): """Return the state of the sensor.""" - sensors = self.get_sensors() - 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 + return self._sensor_data.sensors.get(self._sensor_type) @property def unit_of_measurement(self): """Return the unit of measurement.""" - if self._type == ATTR_TEMP: - 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 - ) - ) + return self._sensor_data.get_unit_of_measurement(self._sensor_type) diff --git a/homeassistant/components/plaato/strings.json b/homeassistant/components/plaato/strings.json index 087cee136833f2eeb3b1229fd9306797494559b1..852ecc88ddec8b16d67d7cc7a98d15c78ff2375a 100644 --- a/homeassistant/components/plaato/strings.json +++ b/homeassistant/components/plaato/strings.json @@ -2,16 +2,53 @@ "config": { "step": { "user": { - "title": "Set up the Plaato Webhook", - "description": "[%key:common::config_flow::description::confirm_setup%]" + "title": "Set up the Plaato devices", + "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": { "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": { - "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)" + } + } } } } diff --git a/homeassistant/components/plaato/translations/en.json b/homeassistant/components/plaato/translations/en.json index 6f25c15583c2b87186cbdae76260d7a1f719b577..64d41d0091ecd4adf4dc3a430165e960bd4f01a2 100644 --- a/homeassistant/components/plaato/translations/en.json +++ b/homeassistant/components/plaato/translations/en.json @@ -1,16 +1,53 @@ { "config": { "abort": { + "already_configured": "Account is already configured", "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." }, "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": { + "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": { + "data": { + "device_name": "Name your device", + "device_type": "Type of Plaato device" + }, "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" } } } diff --git a/requirements_all.txt b/requirements_all.txt index 804b04952978555532d63b1482867505023886d5..c192883c95fccaa62bfda6f9f1baac35d7de5ed6 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1615,6 +1615,9 @@ pypck==0.7.9 # homeassistant.components.pjlink pypjlink2==1.2.1 +# homeassistant.components.plaato +pyplaato==0.0.15 + # homeassistant.components.point pypoint==2.0.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index f855ab4bacc4f9e3ad62e16968057ac9763e22e7..5a30ae13e8c9929c2faefb6ce5018786bbbaed5d 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -842,6 +842,9 @@ pyowm==3.1.1 # homeassistant.components.onewire pyownet==0.10.0.post1 +# homeassistant.components.plaato +pyplaato==0.0.15 + # homeassistant.components.point pypoint==2.0.0 diff --git a/tests/components/plaato/__init__.py b/tests/components/plaato/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..dac4d341790c626f1a1e2f90e73f6e1c7b41966b --- /dev/null +++ b/tests/components/plaato/__init__.py @@ -0,0 +1 @@ +"""Tests for the Plaato integration.""" diff --git a/tests/components/plaato/test_config_flow.py b/tests/components/plaato/test_config_flow.py new file mode 100644 index 0000000000000000000000000000000000000000..10562b6aa60b333109862ebed8ce5de6771621a3 --- /dev/null +++ b/tests/components/plaato/test_config_flow.py @@ -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