Skip to content

Commit

Permalink
Add Withings webhooks (home-assistant#34447)
Browse files Browse the repository at this point in the history
Co-authored-by: Martin Hjelmare <marhje52@gmail.com>
  • Loading branch information
vangorra and MartinHjelmare authored Jun 16, 2020
1 parent 29df13a commit a6a6a7b
Show file tree
Hide file tree
Showing 16 changed files with 2,204 additions and 1,458 deletions.
189 changes: 142 additions & 47 deletions homeassistant/components/withings/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,30 +3,48 @@
For more details about this platform, please refer to the documentation at
"""
import asyncio
from typing import Optional, cast

from aiohttp.web import Request, Response
import voluptuous as vol
from withings_api import WithingsAuth
from withings_api.common import NotifyAppli, enum_or_raise

from homeassistant.components import webhook
from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR_DOMAIN
from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN
from homeassistant.components.webhook import (
async_unregister as async_unregister_webhook,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_CLIENT_ID, CONF_CLIENT_SECRET
from homeassistant.helpers import config_entry_oauth2_flow, config_validation as cv
from homeassistant.helpers.typing import ConfigType, HomeAssistantType

from . import config_flow
from homeassistant.const import CONF_CLIENT_ID, CONF_CLIENT_SECRET, CONF_WEBHOOK_ID
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import ConfigEntryNotReady
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.event import async_call_later
from homeassistant.helpers.typing import ConfigType

from . import config_flow, const
from .common import (
_LOGGER,
NotAuthenticatedError,
WithingsLocalOAuth2Implementation,
get_data_manager,
async_get_data_manager,
async_remove_data_manager,
get_data_manager_by_webhook_id,
json_message_response,
)
from .const import CONF_PROFILES, CONFIG, CREDENTIALS, DOMAIN

DOMAIN = const.DOMAIN

CONFIG_SCHEMA = vol.Schema(
{
DOMAIN: vol.Schema(
{
vol.Required(CONF_CLIENT_ID): vol.All(cv.string, vol.Length(min=1)),
vol.Required(CONF_CLIENT_SECRET): vol.All(cv.string, vol.Length(min=1)),
vol.Required(CONF_PROFILES): vol.All(
vol.Optional(const.CONF_USE_WEBHOOK, default=False): cv.boolean,
vol.Required(const.CONF_PROFILES): vol.All(
cv.ensure_list,
vol.Unique(),
vol.Length(min=1),
Expand All @@ -39,19 +57,21 @@
)


async def async_setup(hass: HomeAssistantType, config: ConfigType) -> bool:
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up the Withings component."""
conf = config.get(DOMAIN, {})
if not conf:
return True

hass.data[DOMAIN] = {CONFIG: conf}
# Make the config available to the oauth2 config flow.
hass.data[DOMAIN] = {const.CONFIG: conf}

# Setup the oauth2 config flow.
config_flow.WithingsFlowHandler.async_register_implementation(
hass,
WithingsLocalOAuth2Implementation(
hass,
DOMAIN,
const.DOMAIN,
conf[CONF_CLIENT_ID],
conf[CONF_CLIENT_SECRET],
f"{WithingsAuth.URL}/oauth2_user/authorize2",
Expand All @@ -62,52 +82,127 @@ async def async_setup(hass: HomeAssistantType, config: ConfigType) -> bool:
return True


async def async_setup_entry(hass: HomeAssistantType, entry: ConfigEntry) -> bool:
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up Withings from a config entry."""
# Upgrading existing token information to hass managed tokens.
if "auth_implementation" not in entry.data:
_LOGGER.debug("Upgrading existing config entry")
data = entry.data
creds = data.get(CREDENTIALS, {})
hass.config_entries.async_update_entry(
entry,
data={
"auth_implementation": DOMAIN,
"implementation": DOMAIN,
"profile": data.get("profile"),
"token": {
"access_token": creds.get("access_token"),
"refresh_token": creds.get("refresh_token"),
"expires_at": int(creds.get("token_expiry")),
"type": creds.get("token_type"),
"userid": creds.get("userid") or creds.get("user_id"),
},
config_updates = {}

# Add a unique id if it's an older config entry.
if entry.unique_id != entry.data["token"]["userid"]:
config_updates["unique_id"] = entry.data["token"]["userid"]

# Add the webhook configuration.
if CONF_WEBHOOK_ID not in entry.data:
webhook_id = webhook.async_generate_id()
config_updates["data"] = {
**entry.data,
**{
const.CONF_USE_WEBHOOK: hass.data[DOMAIN][const.CONFIG][
const.CONF_USE_WEBHOOK
],
CONF_WEBHOOK_ID: webhook_id,
const.CONF_WEBHOOK_URL: entry.data.get(
const.CONF_WEBHOOK_URL,
webhook.async_generate_url(hass, webhook_id),
),
},
)
}

if config_updates:
hass.config_entries.async_update_entry(entry, **config_updates)

data_manager = await async_get_data_manager(hass, entry)

_LOGGER.debug("Confirming %s is authenticated to withings", data_manager.profile)
await data_manager.poll_data_update_coordinator.async_refresh()
if not data_manager.poll_data_update_coordinator.last_update_success:
raise ConfigEntryNotReady()

implementation = await config_entry_oauth2_flow.async_get_config_entry_implementation(
hass, entry
webhook.async_register(
hass,
const.DOMAIN,
"Withings notify",
data_manager.webhook_config.id,
async_webhook_handler,
)

data_manager = get_data_manager(hass, entry, implementation)
# Perform first webhook subscription check.
if data_manager.webhook_config.enabled:
data_manager.async_start_polling_webhook_subscriptions()

_LOGGER.debug("Confirming we're authenticated")
try:
await data_manager.check_authenticated()
except NotAuthenticatedError:
_LOGGER.error(
"Withings auth tokens exired for profile %s, remove and re-add the integration",
data_manager.profile,
)
return False
@callback
def async_call_later_callback(now) -> None:
hass.async_create_task(
data_manager.subscription_update_coordinator.async_refresh()
)

# Start subscription check in the background, outside this component's setup.
async_call_later(hass, 1, async_call_later_callback)

hass.async_create_task(
hass.config_entries.async_forward_entry_setup(entry, "sensor")
hass.config_entries.async_forward_entry_setup(entry, BINARY_SENSOR_DOMAIN)
)
hass.async_create_task(
hass.config_entries.async_forward_entry_setup(entry, SENSOR_DOMAIN)
)

return True


async def async_unload_entry(hass: HomeAssistantType, entry: ConfigEntry) -> bool:
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Unload Withings config entry."""
return await hass.config_entries.async_forward_entry_unload(entry, "sensor")
data_manager = await async_get_data_manager(hass, entry)
data_manager.async_stop_polling_webhook_subscriptions()

async_unregister_webhook(hass, data_manager.webhook_config.id)

await asyncio.gather(
data_manager.async_unsubscribe_webhook(),
hass.config_entries.async_forward_entry_unload(entry, BINARY_SENSOR_DOMAIN),
hass.config_entries.async_forward_entry_unload(entry, SENSOR_DOMAIN),
)

async_remove_data_manager(hass, entry)

return True


async def async_webhook_handler(
hass: HomeAssistant, webhook_id: str, request: Request
) -> Optional[Response]:
"""Handle webhooks calls."""
# Handle http head calls to the path.
# When creating a notify subscription, Withings will check that the endpoint is running by sending a HEAD request.
if request.method.upper() == "HEAD":
return Response()

if request.method.upper() != "POST":
return json_message_response("Invalid method.", message_code=2)

# Handle http post calls to the path.
if not request.body_exists:
return json_message_response("No request body.", message_code=12)

params = await request.post()

if "appli" not in params:
return json_message_response("Parameter appli not provided", message_code=20)

try:
appli = cast(
NotifyAppli, enum_or_raise(int(params.getone("appli")), NotifyAppli)
)
except ValueError:
return json_message_response("Invalid appli provided", message_code=21)

data_manager = get_data_manager_by_webhook_id(hass, webhook_id)
if not data_manager:
_LOGGER.error(
"Webhook id %s not handled by data manager. This is a bug and should be reported.",
webhook_id,
)
return json_message_response("User not found", message_code=1)

# Run this in the background and return immediately.
hass.async_create_task(data_manager.async_webhook_data_updated(appli))

return json_message_response("Success", message_code=0)
40 changes: 40 additions & 0 deletions homeassistant/components/withings/binary_sensor.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
"""Sensors flow for Withings."""
from typing import Callable, List

from homeassistant.components.binary_sensor import (
DEVICE_CLASS_PRESENCE,
DOMAIN as BINARY_SENSOR_DOMAIN,
BinarySensorDevice,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity import Entity

from .common import BaseWithingsSensor, async_create_entities


async def async_setup_entry(
hass: HomeAssistant,
entry: ConfigEntry,
async_add_entities: Callable[[List[Entity], bool], None],
) -> None:
"""Set up the sensor config entry."""
entities = await async_create_entities(
hass, entry, WithingsHealthBinarySensor, BINARY_SENSOR_DOMAIN
)

async_add_entities(entities, True)


class WithingsHealthBinarySensor(BaseWithingsSensor, BinarySensorDevice):
"""Implementation of a Withings sensor."""

@property
def is_on(self) -> bool:
"""Return true if the binary sensor is on."""
return self._state_data

@property
def device_class(self) -> str:
"""Provide the device class."""
return DEVICE_CLASS_PRESENCE
Loading

0 comments on commit a6a6a7b

Please sign in to comment.