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
Merged
Show file tree
Hide file tree
Changes from 15 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .coveragerc
Original file line number Diff line number Diff line change
Expand Up @@ -451,6 +451,7 @@ omit =
homeassistant/components/ping/device_tracker.py
homeassistant/components/pioneer/media_player.py
homeassistant/components/pjlink/media_player.py
homeassistant/components/plaato/*
balloob marked this conversation as resolved.
Show resolved Hide resolved
homeassistant/components/plex/media_player.py
homeassistant/components/plex/sensor.py
homeassistant/components/plum_lightpad/*
Expand Down
1 change: 1 addition & 0 deletions CODEOWNERS
Validating CODEOWNERS rules …
Original file line number Diff line number Diff line change
Expand Up @@ -191,6 +191,7 @@ homeassistant/components/panel_iframe/* @home-assistant/frontend
homeassistant/components/persistent_notification/* @home-assistant/core
homeassistant/components/philips_js/* @elupus
homeassistant/components/pi_hole/* @fabaff
homeassistant/components/plaato/* @JohNan
homeassistant/components/plant/* @ChristianKuehnel
homeassistant/components/point/* @fredrike
homeassistant/components/ps4/* @ktnrg45
Expand Down
18 changes: 18 additions & 0 deletions homeassistant/components/plaato/.translations/en.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
{
"config": {
"abort": {
"not_internet_accessible": "Your Home Assistant instance needs to be accessible from the internet to receive messages from Plaato Airlock.",
"one_instance_allowed": "Only a single instance is necessary."
},
"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."
},
"step": {
"user": {
"description": "Are you sure you want to set up the Plaato Airlock?",
"title": "Set up the Plaato Webhook"
}
},
"title": "Plaato Airlock"
}
}
126 changes: 126 additions & 0 deletions homeassistant/components/plaato/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
"""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,
TEMP_CELSIUS, TEMP_FAHRENHEIT, VOLUME_GALLONS, VOLUME_LITERS)
import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.dispatcher import async_dispatcher_send
from .const import DOMAIN

_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 = '{}_sensor_update'.format(DOMAIN)
SENSOR_DATA_KEY = '{}.{}'.format(DOMAIN, SENSOR)

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):
JohNan marked this conversation as resolved.
Show resolved Hide resolved
"""Unload a config entry."""
hass.components.webhook.async_unregister(entry.data[CONF_WEBHOOK_ID])
hass.data[SENSOR_DATA_KEY]()

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:
_LOGGER.warning("An error occurred when parsing webhook data <%s>",
error)
return

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))
11 changes: 11 additions & 0 deletions homeassistant/components/plaato/config_flow.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
"""Config flow for GPSLogger."""
from homeassistant.helpers import config_entry_flow
from .const import DOMAIN

config_entry_flow.register_webhook_flow(
DOMAIN,
'Webhook',
{
'docs_url': 'https://www.home-assistant.io/components/plaato/'
}
)
3 changes: 3 additions & 0 deletions homeassistant/components/plaato/const.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
"""Const for GPSLogger."""

DOMAIN = 'plaato'
9 changes: 9 additions & 0 deletions homeassistant/components/plaato/manifest.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
{
"domain": "plaato",
"name": "Plaato Airlock",
"config_flow": true,
"documentation": "https://www.home-assistant.io/components/plaato",
"dependencies": ["webhook"],
"codeowners": ["@JohNan"],
"requirements": []
}
140 changes: 140 additions & 0 deletions homeassistant/components/plaato/sensor.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
"""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_DATA_KEY, SENSOR_UPDATE)

_LOGGER = logging.getLogger(__name__)


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 in sensors:
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()
Copy link
Member

Choose a reason for hiding this comment

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

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)

Copy link
Member

Choose a reason for hiding this comment

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

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

Copy link
Contributor Author

Choose a reason for hiding this comment

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

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)

Copy link
Member

@balloob balloob Jun 14, 2019

Choose a reason for hiding this comment

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

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

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Done!

Copy link
Member

Choose a reason for hiding this comment

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

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.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

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.

Copy link
Member

@MartinHjelmare MartinHjelmare Jun 18, 2019

Choose a reason for hiding this comment

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

Something like this:
https://github.com/home-assistant/home-assistant/blob/f382be4c15ef32f557ba6d40bb01c084c5cbc692/homeassistant/components/aftership/sensor.py#L145-L153

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:
https://github.com/home-assistant/home-assistant/blob/f382be4c15ef32f557ba6d40bb01c084c5cbc692/homeassistant/components/aftership/sensor.py#L85

Copy link
Contributor Author

@JohNan JohNan Jun 19, 2019

Choose a reason for hiding this comment

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

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

Copy link
Contributor Author

Choose a reason for hiding this comment

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

@MartinHjelmare PR with the changes you requested: #24627


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

return True


class PlaatoSensor(Entity):
JohNan marked this conversation as resolved.
Show resolved Hide resolved
"""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."""
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)

@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)
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 '%'

return ''

@property
def should_poll(self):
"""Return the polling state."""
return False
18 changes: 18 additions & 0 deletions homeassistant/components/plaato/strings.json
Original file line number Diff line number Diff line change
@@ -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."
}
}
}
1 change: 1 addition & 0 deletions homeassistant/generated/config_flows.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@
"nest",
"openuv",
"owntracks",
"plaato",
"point",
"ps4",
"rainmachine",
Expand Down