Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Adds integration for Plaato Airlock #23727

Merged
merged 16 commits into from Jun 18, 2019
Next

Adds integration for Plaato Airlock

  • Loading branch information...
JohNan committed May 6, 2019
commit 1a99eccf5c2df4996e3ffa47631348221cb48045
@@ -0,0 +1,18 @@
{
"config": {
"abort": {
"not_internet_accessible": "Din Home Assistant instans m\u00e5ste vara \u00e5tkomlig ifr\u00e5n internet f\u00f6r att ta emot meddelanden ifr\u00e5n Plaato Airlock.",
"one_instance_allowed": "Endast en enda instans \u00e4r n\u00f6dv\u00e4ndig."
},
"create_entry": {
"default": "F\u00f6r att skicka h\u00e4ndelser till Home Assistant m\u00e5ste du konfigurera webhook funktionen i Plaato.\n\n Fyll i f\u00f6ljande information:\n \n- URL: `{webhook_url}`\n- Method: POST\n\nSe [dokumentation]({docs_url}) om hur du konfigurerar detta f\u00f6r mer information."
},
"step": {
"user": {
"description": "\u00c4r du s\u00e4ker p\u00e5 att du vill konfigurera Plaato Airlock?",
"title": "Konfigurera Plaato Airlock"
}
},
"title": "Plaato Airlock"
}
}
@@ -0,0 +1,135 @@
"""Support for Plaato Airlock."""
import logging

from aiohttp import web
import voluptuous as vol

from homeassistant.components.sensor import DOMAIN as SENSOR
from homeassistant.const import (
CONF_WEBHOOK_ID, HTTP_OK, HTTP_UNPROCESSABLE_ENTITY,
TEMP_CELSIUS, TEMP_FAHRENHEIT, VOLUME_GALLONS, VOLUME_LITERS)
from homeassistant.helpers import config_entry_flow
import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.dispatcher import async_dispatcher_send

_LOGGER = logging.getLogger(__name__)

DOMAIN = 'plaato'
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 = '{}_sensor_update'.format(DOMAIN)

WEBHOOK_SCHEMA = vol.Schema({
vol.Required(ATTR_DEVICE_NAME): cv.string,
vol.Required(ATTR_DEVICE_ID): cv.positive_int,
vol.Required(ATTR_TEMP_UNIT): vol.Any(TEMP_CELSIUS, TEMP_FAHRENHEIT),
vol.Required(ATTR_VOLUME_UNIT): vol.Any(VOLUME_LITERS, VOLUME_GALLONS),
vol.Required(ATTR_BPM): cv.positive_int,
vol.Required(ATTR_TEMP): vol.Coerce(float),
vol.Required(ATTR_SG): vol.Coerce(float),
vol.Required(ATTR_OG): vol.Coerce(float),
vol.Required(ATTR_ABV): vol.Coerce(float),
vol.Required(ATTR_CO2_VOLUME): vol.Coerce(float),
vol.Required(ATTR_BATCH_VOLUME): vol.Coerce(float),
vol.Required(ATTR_BUBBLES): cv.positive_int,
}, extra=vol.ALLOW_EXTRA)


async def async_setup(hass, hass_config):
"""Set up the Plaato component."""
return True


async def async_setup_entry(hass, entry):
"""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)

hass.async_create_task(
hass.config_entries.async_forward_entry_setup(entry, SENSOR)
)

return True


async def async_unload_entry(hass, entry):
This conversation was marked as resolved by JohNan

This comment has been minimized.

Copy link
@balloob

balloob Jun 6, 2019

Member

You also need to call the dispatcher unsubscribe function stored in hass.data["plaato.sensor"]

This comment has been minimized.

Copy link
@JohNan

JohNan Jun 13, 2019

Author Contributor

@balloob I don't understand what you mean. Can you refer to documentation or an example?

This comment has been minimized.

Copy link
@balloob

balloob Jun 14, 2019

Member

You connect a dispatcher when you set up the entry. So during unloading, you need to disconnect that dispatcher signal listener.

This comment has been minimized.

Copy link
@JohNan

JohNan Jun 17, 2019

Author Contributor

@balloob I think I got it now!

"""Unload a config entry."""
hass.components.webhook.async_unregister(entry.data[CONF_WEBHOOK_ID])

await hass.config_entries.async_forward_entry_unload(entry, SENSOR)
return True


async def handle_webhook(hass, webhook_id, request):
"""Handle incoming webhook from Plaato."""
try:
data = WEBHOOK_SCHEMA(await request.json())
except vol.MultipleInvalid as error:
return web.Response(
body=error.error_message,
status=HTTP_UNPROCESSABLE_ENTITY
This conversation was marked as resolved by balloob

This comment has been minimized.

Copy link
@balloob

balloob Jun 6, 2019

Member

It's sometimes better to just drop it and log a warning instead, or else some services will keep retrying the webhook to ensure data is received.

This comment has been minimized.

Copy link
@JohNan

JohNan Jun 13, 2019

Author Contributor

Done

)

device_id = _device_id(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)

return web.Response(
text="Saving status for {}".format(device_id), status=HTTP_OK)


def _device_id(data):
"""Return name of device sensor."""
return "{}_{}".format(data.get(ATTR_DEVICE_NAME), data.get(ATTR_DEVICE_ID))


config_entry_flow.register_webhook_flow(
DOMAIN,
'Webhook',
{
'docs_url': 'https://www.home-assistant.io/components/plaato/'
}
)
@@ -0,0 +1,8 @@
{
"domain": "plaato",
"name": "Plaato Airlock",
"documentation": "https://www.home-assistant.io/components/plaato",
"dependencies": ["webhook"],
"codeowners": ["@JohNan"],
"requirements": []
}
@@ -0,0 +1,142 @@
"""Support for Plaato Airlock sensors."""

import logging

from homeassistant.helpers.dispatcher import async_dispatcher_connect
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 as SENSOR_DOMAIN,
SENSOR_UPDATE)

_LOGGER = logging.getLogger(__name__)

DATA_KEY = '{}.{}'.format(PLAATO_DOMAIN, SENSOR_DOMAIN)


async def async_setup_platform(hass, config, async_add_entities,
discovery_info=None):
"""Set up the Plaato sensor."""


async def async_setup_entry(hass, config_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)

async def _update_sensor(device_id):
"""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, value in sensors.items():
entities.append(PlaatoSensor(device_id, sensor_type))

devices[device_id] = entities

async_add_entities(entities, True)
else:
for entity in devices[device_id]:
entity.async_schedule_update_ha_state()

This comment has been minimized.

Copy link
@balloob

balloob Jun 6, 2019

Member

Pass True so that it will call the async_update method and fetch the latest data.

Suggested change
entity.async_schedule_update_ha_state()
entity.async_schedule_update_ha_state(True)

This comment has been minimized.

Copy link
@balloob

balloob Jun 6, 2019

Member

Alternative, since the data is just fetched from a dictionary stored in memory during update, you could have all properties fetch the data directly from that dictionary instead of storing it on the entity instance. In that case you can skip True

This comment has been minimized.

Copy link
@JohNan

JohNan Jun 13, 2019

Author Contributor

What you are suggesting would mean that I should move things from async_update to the state property? Something like this?

    @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)
        elif self._type == ATTR_TEMP:
              return round(sensors.get(self._type), 1)
        elif self._type == ATTR_CO2_VOLUME:
            return round(sensors.get(self._type), 2)
        else:
            return sensors.get(self._type)

This comment has been minimized.

Copy link
@balloob

balloob Jun 14, 2019

Member

yeah. Just know that you cannot do any I/O inside properties.

This comment has been minimized.

Copy link
@JohNan

JohNan Jun 17, 2019

Author Contributor

Done!

This comment has been minimized.

Copy link
@MartinHjelmare

MartinHjelmare Jun 18, 2019

Member

It's not safe to call entity.async_schedule_update_ha_state directly in non polling platforms.

For polling platforms we can assume that the interval between polls is long enough so that all new entities will have been added to home assistant before the next update, via poll, is done and calls this method.

For non polling platforms we can't assume this. The next update for new entities might come immediately after the first one, before they have been added to home assistant. Then this call will error.

The safe approach in this case is to use our dispatch helper and send a signal to the entity to have it call async_schedule_update_ha_state from inside itself. The signal should be connected in async_added_to_hass which will guard from the error.

This comment has been minimized.

Copy link
@JohNan

JohNan Jun 18, 2019

Author Contributor

Thank @MartinHjelmare I will look into this. You don't happen to have any examples where this behavior is used? So I can learn how to do it.

This comment has been minimized.

Copy link
@MartinHjelmare

MartinHjelmare Jun 18, 2019

Member

Something like this:

async def async_added_to_hass(self):
"""Register callbacks."""
self.hass.helpers.dispatcher.async_dispatcher_connect(
UPDATE_TOPIC, self.force_update)
async def force_update(self):
"""Force update of data."""
await self.async_update(no_throttle=True)
await self.async_update_ha_state()

In our case we might not even need an extra method that does the update call. We can connect async_schedule_update_ha_state directly. So replace self.force_update with self.async_schedule_update_ha_state.

Send the signal to update like so:

async_dispatcher_send(hass, UPDATE_TOPIC)

This comment has been minimized.

Copy link
@JohNan

JohNan Jun 19, 2019

Author Contributor

@MartinHjelmare Thank you. I'll open up a new PR for this change.

This comment has been minimized.

Copy link
@JohNan

JohNan Jun 19, 2019

Author Contributor

@MartinHjelmare PR with the changes you requested: #24627


hass.data[DATA_KEY] = async_dispatcher_connect(
hass, SENSOR_UPDATE, _update_sensor
)

return True


class PlaatoSensor(Entity):
This conversation was marked as resolved by JohNan

This comment has been minimized.

Copy link
@balloob

balloob Jun 6, 2019

Member

Please add property should_poll and have it return False

This comment has been minimized.

Copy link
@JohNan

JohNan Jun 13, 2019

Author Contributor

Done

"""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 = "{} {}".format(device_id, sensor_type)
self._attributes = None

@property
def name(self):
"""Return the name of the sensor."""
return "{} {}".format(PLAATO_DOMAIN, self._name)

@property
def unique_id(self):
"""Return the unique ID of this sensor."""
return "{}_{}".format(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, '')

@property
def state(self):
"""Return the state of the sensor."""
return self._state

@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 unit_of_measurement(self):
"""Return the unit of measurement."""
if self._type == ATTR_TEMP:
return self.get_sensors_unit_of_measurement(ATTR_TEMP_UNIT)
elif self._type == ATTR_BATCH_VOLUME or self._type == ATTR_CO2_VOLUME:
return self.get_sensors_unit_of_measurement(ATTR_VOLUME_UNIT)
elif self._type == ATTR_BPM:
return 'bpm'
elif self._type == ATTR_ABV:
return '%'

return ''

async def async_update(self):
"""Fetch new state data for the sensor."""
sensors = self.get_sensors()
if sensors is False:
_LOGGER.debug("Device with name %s has no sensors.", self.name)
return

if self._type == ATTR_ABV:
self._state = round(sensors.get(self._type), 2)
elif self._type == ATTR_TEMP:
self._state = round(sensors.get(self._type), 1)
elif self._type == ATTR_CO2_VOLUME:
self._state = round(sensors.get(self._type), 2)
else:
self._state = sensors.get(self._type)
@@ -0,0 +1,18 @@
{
"config": {
"title": "Plaato Airlock",
"step": {
"user": {
"title": "Set up the Plaato Webhook",
"description": "Are you sure you want to set up the Plaato Airlock?"
}
},
"abort": {
"one_instance_allowed": "Only a single instance is necessary.",
"not_internet_accessible": "Your Home Assistant instance needs to be accessible from the internet to receive messages from Plaato Airlock."
},
"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."
}
}
}
ProTip! Use n and p to navigate between commits in a pull request.
You can’t perform that action at this time.