Skip to content

Commit

Permalink
rework to better match integration_blueprint
Browse files Browse the repository at this point in the history
  • Loading branch information
jcgoette committed Dec 7, 2021
1 parent 8d157c2 commit eb49238
Show file tree
Hide file tree
Showing 9 changed files with 509 additions and 176 deletions.
56 changes: 45 additions & 11 deletions custom_components/weight_gurus/__init__.py
@@ -1,21 +1,55 @@
"""Weight Gurus integration"""
from .const import DOMAIN
"""weight_gurus integration."""
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_EMAIL, CONF_PASSWORD
from homeassistant.core import HomeAssistant
from homeassistant.helpers.aiohttp_client import async_get_clientsession

from .api import WeightGurusApiClient
from .const import DOMAIN, PLATFORMS
from .coordinator import WeightGurusDataUpdateCoordinator

async def async_setup_entry(hass, entry):
"""Set up Weight Gurus platform from a ConfigEntry."""

async def async_setup_entry(
hass: HomeAssistant,
entry: ConfigEntry,
) -> bool:
"""Set up the config entry."""
hass.data.setdefault(DOMAIN, {})
hass.data[DOMAIN][entry.entry_id] = entry.data

hass.async_create_task(
hass.config_entries.async_forward_entry_setup(entry, "sensor")
api_client = WeightGurusApiClient(
email=entry.data[CONF_EMAIL],
password=entry.data[CONF_PASSWORD],
session=async_get_clientsession(hass),
)

return True
coordinator = WeightGurusDataUpdateCoordinator(hass, api=api_client)
await coordinator.async_config_entry_first_refresh()

hass.data[DOMAIN][entry.entry_id] = coordinator

async def async_unload_entry(hass, entry):
"""Unload Weight Gurus entity."""
await hass.config_entries.async_forward_entry_unload(entry, "sensor")
for platform in PLATFORMS:
if entry.options.get(platform, True):
coordinator.platforms.append(platform)
hass.async_add_job(
hass.config_entries.async_forward_entry_setup(entry, platform)
)

entry.add_update_listener(async_reload_entry)

return True


async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Unload a config entry."""
coordinator = hass.data[DOMAIN][entry.entry_id]
if unload_ok := await hass.config_entries.async_unload_platforms(
entry, [platform for platform in PLATFORMS if platform in coordinator.platforms]
):
hass.data[DOMAIN].pop(entry.entry_id)
return unload_ok


async def async_reload_entry(hass: HomeAssistant, entry: ConfigEntry) -> None:
"""Reload config entry."""
await async_unload_entry(hass, entry)
await async_setup_entry(hass, entry)
98 changes: 98 additions & 0 deletions custom_components/weight_gurus/api.py
@@ -0,0 +1,98 @@
"""weight_gurus API."""
import asyncio
import socket

import aiohttp
import async_timeout
from homeassistant.const import CONF_EMAIL, CONF_PASSWORD

from .const import LOGGER

TIMEOUT = 10


class ApiClientException(Exception):
"""Api Client Exception."""


class WeightGurusApiClient:
# TODO: refactor this class so functions live in appropriate method subnodes
def __init__(
self, email: str, password: str, session: aiohttp.ClientSession
) -> None:
"""Initialize the API client."""
self._email = email
self._password = password
self._session = session
self._account_login_dict: dict = {}
self._token_expires_at: str = ""

async def async_get_data(self) -> dict:
"""Get data from the API."""
url = "https://api.weightgurus.com/v3/operation/"
headers = await self.async_build_headers()
data = await self.api_wrapper("get", url, {}, headers)
return await self.get_last_entry_and_merge_dicts(data)

async def async_build_headers(self) -> dict:
"""Build headers for the API."""
account_access_token = await self.async_get_token_and_save_account_dict()
headers = {
"Authorization": f"Bearer {account_access_token}",
"Accept": "application/json, text/plain, */*",
}
return headers

async def async_get_token_and_save_account_dict(self) -> dict:
"""Get account access token and save account dict."""
# TODO: check self._token_expires_at before requesting new token (but this might not be a good idea if goalType, goalWeight, and initialWeight change frequently)
account_credentials = {CONF_EMAIL: self._email, CONF_PASSWORD: self._password}
account_login_response = await self._session.post(
f"https://api.weightgurus.com/v3/account/login", data=account_credentials
)
account_login_json = await account_login_response.json()
self._token_expires_at = account_login_json["expiresAt"]
self._account_login_dict = account_login_json["account"]
account_access_token = account_login_json["accessToken"]
return account_access_token

async def get_last_entry_and_merge_dicts(self, data: dict) -> dict:
"""Get last entry and merge dicts."""
sorted_data = sorted(
data["operations"],
key=lambda x: x["entryTimestamp"],
)
last_entry = sorted_data[-1:][0]
merged_dicts = {**self._account_login_dict, **last_entry}
return merged_dicts

async def api_wrapper(
self, method: str, url: str, data: dict = {}, headers: dict = {}
) -> dict:
"""API wrapper."""
try:
async with async_timeout.timeout(TIMEOUT):
if method == "get":
response = await self._session.get(url, headers=headers)
return await response.json()

elif method == "put":
await self._session.put(url, headers=headers, json=data)

elif method == "patch":
await self._session.patch(url, headers=headers, json=data)

elif method == "post":
await self._session.post(url, headers=headers, json=data)

except asyncio.TimeoutError as exception:
LOGGER.error(
"Timeout error fetching information from %s - %s", url, exception
)

except (KeyError, TypeError) as exception:
LOGGER.error("Error parsing information from %s - %s", url, exception)
except (aiohttp.ClientError, socket.gaierror) as exception:
LOGGER.error("Error fetching information from %s - %s", url, exception)
except Exception as exception: # pylint: disable=broad-except
LOGGER.error("Something really wrong happened! - %s", exception)
118 changes: 109 additions & 9 deletions custom_components/weight_gurus/config_flow.py
@@ -1,24 +1,124 @@
import homeassistant.helpers.config_validation as cv
"""weight_gurus configuration flow."""
from __future__ import annotations

from typing import Dict

import voluptuous as vol
from homeassistant import config_entries
from homeassistant.config_entries import ConfigEntry, OptionsFlow
from homeassistant.const import CONF_EMAIL, CONF_PASSWORD
from homeassistant.core import callback
from homeassistant.data_entry_flow import FlowResult
from homeassistant.helpers.aiohttp_client import async_create_clientsession

from .api import WeightGurusApiClient
from .const import DOMAIN, PLATFORMS


class WeightGurusFlowHandler(config_entries.ConfigFlow, domain=DOMAIN):
"""Handle a config flow."""

VERSION = 1
CONNECTION_CLASS = config_entries.CONN_CLASS_CLOUD_POLL

def __init__(self) -> None:
"""Initialize."""
self._errors: dict = {}

async def async_step_user(
self, user_input: Dict[str, str] | None = None
) -> FlowResult:
"""Handle a flow initiated by the user."""
self._errors = {}

# Uncomment the next 2 lines if only a single instance of the integration is allowed:
# if self._async_current_entries():
# return self.async_abort(reason="single_instance_allowed")

if user_input is not None:
valid = await self._test_credentials(
user_input[CONF_EMAIL], user_input[CONF_PASSWORD]
)
if valid:
return self.async_create_entry(
title=user_input[CONF_EMAIL], data=user_input
)
else:
self._errors["base"] = "auth"

return await self._show_config_form(user_input)

user_input = {}
# Provide defaults for form
user_input[CONF_EMAIL] = ""
user_input[CONF_PASSWORD] = ""

return await self._show_config_form(user_input)

from .const import ATTR_DEFAULT_NAME, DOMAIN
@staticmethod
@callback
def async_get_options_flow(config_entry: ConfigEntry) -> OptionsFlow:
return WeightGurusOptionsFlowHandler(config_entry)

async def _show_config_form(
self, user_input: Dict[str, str] | None = None
) -> OptionsFlow: # pylint: disable=unused-argument
"""Show the configuration form to edit location data."""
return self.async_show_form(
step_id="user",
data_schema=vol.Schema(
{
vol.Required(CONF_EMAIL, default=user_input[CONF_EMAIL]): str,
vol.Required(CONF_PASSWORD, default=user_input[CONF_PASSWORD]): str,
}
),
errors=self._errors,
)

# TODO: better validation of data
# TODO: translations
class WeightGurusConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
async def async_step_user(self, user_input):
async def _test_credentials(self, email: str, password: str) -> OptionsFlow:
"""Test if the credentials are valid."""
try:
session = async_create_clientsession(self.hass)
client = WeightGurusApiClient(email, password, session)
await client.async_get_data()
return True
except Exception: # pylint: disable=broad-except
pass
return False


class WeightGurusOptionsFlowHandler(config_entries.OptionsFlow):
"""Handle a option flow."""

def __init__(self, config_entry: ConfigEntry) -> None:
"""Initialize options flow."""
self.config_entry = config_entry
self.options = dict(config_entry.options)

async def async_step_init(
self, user_input: dict | None = None
) -> FlowResult: # pylint: disable=unused-argument
"""Handle options flow."""
return await self.async_step_user()

async def async_step_user(self, user_input: dict | None = None) -> FlowResult:
"""Handle a flow initiated by the user."""
if user_input is not None:
return self.async_create_entry(title=ATTR_DEFAULT_NAME, data=user_input)
self.options.update(user_input)
return await self._update_options()

return self.async_show_form(
step_id="user",
data_schema=vol.Schema(
{
vol.Required(CONF_EMAIL): cv.string,
vol.Required(CONF_PASSWORD): cv.string,
vol.Required(x, default=self.options.get(x, True)): bool
for x in sorted(PLATFORMS)
}
),
)

async def _update_options(self) -> FlowResult:
"""Update options."""
return self.async_create_entry(
title=self.config_entry.data.get(CONF_EMAIL), data=self.options
)
32 changes: 21 additions & 11 deletions custom_components/weight_gurus/const.py
@@ -1,15 +1,25 @@
"""Weight Gurus integration constants"""
"""weight_gurus constants."""
from __future__ import annotations

from datetime import timedelta
from logging import Logger, getLogger

# Base component constants
# TODO: allow to be overridden by config_flow
DATA_COORDINATOR_UPDATE_INTERVAL = timedelta(minutes=5)
DOMAIN = "weight_gurus"
NAME = "Weight Gurus"
VERSION = "v1.2.0"
LOGGER: Logger = getLogger(__package__)

# Platforms
SENSOR = "sensor"
PLATFORMS = [SENSOR]

# Configuration and options

ATTR_ACCESS_TOKEN = "accessToken"
ATTR_ACCOUNT = "account"
ATTR_DECIMAL_VALUES = "decimalValues"
ATTR_DEFAULT_NAME = "Weight Gurus"
ATTR_EMAIL_REGEX = "(^[a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\.[a-zA-Z0-9-.]+$)"
ATTR_ENTRY_TIMESTAMP = "entryTimestamp"
# Attributes
ATTR_ACTIVITY_LEVEL = "activityLevel"
ATTR_FIRST_NAME = "firstName"
ATTR_ICON = "mdi:scale-bathroom"
ATTR_HEIGHT = "height"
ATTR_LAST_NAME = "lastName"
ATTR_OPERATIONS = "operations"
ATTR_URL = "https://api.weightgurus.com/v3"
ATTR_WEIGHT = "weight"
36 changes: 36 additions & 0 deletions custom_components/weight_gurus/coordinator.py
@@ -0,0 +1,36 @@
"""weight_gurus coordinator."""
from __future__ import annotations

from typing import Any

from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed

from .api import ApiClientException, WeightGurusApiClient
from .const import DATA_COORDINATOR_UPDATE_INTERVAL, DOMAIN, LOGGER


class WeightGurusDataUpdateCoordinator(DataUpdateCoordinator):
"""WeightGurus data update coordinator."""

config_entry: ConfigEntry

def __init__(self, hass: HomeAssistant, api: WeightGurusApiClient) -> None:
"""Initialize the data update coordinator."""
self.api = api
self.platforms: list[str] = []

super().__init__(
hass,
logger=LOGGER,
name=DOMAIN,
update_interval=DATA_COORDINATOR_UPDATE_INTERVAL,
)

async def _async_update_data(self) -> dict[str, Any]:
"""Update data via library."""
try:
return await self.api.async_get_data()
except ApiClientException as exception:
raise UpdateFailed(exception) from exception

0 comments on commit eb49238

Please sign in to comment.