-
-
Notifications
You must be signed in to change notification settings - Fork 1
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Merge pull request #6 from jcgoette/v2.0.0
rework to better match integration_blueprint
- Loading branch information
Showing
10 changed files
with
510 additions
and
180 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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 | ||
) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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" |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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 |
Oops, something went wrong.