Update Fitbit integration to allow UI based configuration (#100897)

* Cleanup fitbit sensor API parsing

* Remove API code that is not used yet

* Configuration flow for fitbit

* Code cleanup after manual review

* Streamline code for review

* Use scopes to determine which entities to enable

* Use set for entity comparisons

* Apply fitbit string pr feedback

* Improve fitbit configuration flow error handling

* Apply suggestions from code review

Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>

* Fix typo in more places

* Revert typing import

* Revert custom domain back to default

* Add additional config flow tests

* Add         breaks_in_ha_version to repair issues

* Update homeassistant/components/fitbit/api.py

Co-authored-by: Martin Hjelmare <marhje52@gmail.com>

* Increase test coverage for token refresh success case

* Add breaks_in_ha_version for sensor issue

* Apply suggestions from code review

Co-authored-by: Martin Hjelmare <marhje52@gmail.com>

* Simplify translations, issue keys, and token refresh

* Config flow test improvements

* Simplify repair issue creation on fitbit import

* Remove unused strings

---------

Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
Co-authored-by: Martin Hjelmare <marhje52@gmail.com>
This commit is contained in:
Allen Porter 2023-09-30 16:56:39 -07:00 committed by GitHub
parent fe30c019b6
commit bd2fee289d
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
15 changed files with 1188 additions and 302 deletions

View file

@ -1,11 +1,14 @@
"""API for fitbit bound to Home Assistant OAuth."""
from abc import ABC, abstractmethod
import logging
from typing import Any, cast
from fitbit import Fitbit
from homeassistant.const import CONF_ACCESS_TOKEN
from homeassistant.core import HomeAssistant
from homeassistant.helpers import config_entry_oauth2_flow
from homeassistant.util.unit_system import METRIC_SYSTEM
from .const import FitbitUnitSystem
@ -13,32 +16,50 @@ from .model import FitbitDevice, FitbitProfile
_LOGGER = logging.getLogger(__name__)
CONF_REFRESH_TOKEN = "refresh_token"
CONF_EXPIRES_AT = "expires_at"
class FitbitApi:
"""Fitbit client library wrapper base class."""
class FitbitApi(ABC):
"""Fitbit client library wrapper base class.
This can be subclassed with different implementations for providing an access
token depending on the use case.
"""
def __init__(
self,
hass: HomeAssistant,
client: Fitbit,
unit_system: FitbitUnitSystem | None = None,
) -> None:
"""Initialize Fitbit auth."""
self._hass = hass
self._profile: FitbitProfile | None = None
self._client = client
self._unit_system = unit_system
@property
def client(self) -> Fitbit:
"""Property to expose the underlying client library."""
return self._client
@abstractmethod
async def async_get_access_token(self) -> dict[str, Any]:
"""Return a valid token dictionary for the Fitbit API."""
async def _async_get_client(self) -> Fitbit:
"""Get synchronous client library, called before each client request."""
# Always rely on Home Assistant's token update mechanism which refreshes
# the data in the configuration entry.
token = await self.async_get_access_token()
return Fitbit(
client_id=None,
client_secret=None,
access_token=token[CONF_ACCESS_TOKEN],
refresh_token=token[CONF_REFRESH_TOKEN],
expires_at=float(token[CONF_EXPIRES_AT]),
)
async def async_get_user_profile(self) -> FitbitProfile:
"""Return the user profile from the API."""
if self._profile is None:
client = await self._async_get_client()
response: dict[str, Any] = await self._hass.async_add_executor_job(
self._client.user_profile_get
client.user_profile_get
)
_LOGGER.debug("user_profile_get=%s", response)
profile = response["user"]
@ -73,8 +94,9 @@ class FitbitApi:
async def async_get_devices(self) -> list[FitbitDevice]:
"""Return available devices."""
client = await self._async_get_client()
devices: list[dict[str, str]] = await self._hass.async_add_executor_job(
self._client.get_devices
client.get_devices
)
_LOGGER.debug("get_devices=%s", devices)
return [
@ -90,17 +112,56 @@ class FitbitApi:
async def async_get_latest_time_series(self, resource_type: str) -> dict[str, Any]:
"""Return the most recent value from the time series for the specified resource type."""
client = await self._async_get_client()
# Set request header based on the configured unit system
self._client.system = await self.async_get_unit_system()
client.system = await self.async_get_unit_system()
def _time_series() -> dict[str, Any]:
return cast(
dict[str, Any], self._client.time_series(resource_type, period="7d")
)
return cast(dict[str, Any], client.time_series(resource_type, period="7d"))
response: dict[str, Any] = await self._hass.async_add_executor_job(_time_series)
_LOGGER.debug("time_series(%s)=%s", resource_type, response)
key = resource_type.replace("/", "-")
dated_results: list[dict[str, Any]] = response[key]
return dated_results[-1]
class OAuthFitbitApi(FitbitApi):
"""Provide fitbit authentication tied to an OAuth2 based config entry."""
def __init__(
self,
hass: HomeAssistant,
oauth_session: config_entry_oauth2_flow.OAuth2Session,
unit_system: FitbitUnitSystem | None = None,
) -> None:
"""Initialize OAuthFitbitApi."""
super().__init__(hass, unit_system)
self._oauth_session = oauth_session
async def async_get_access_token(self) -> dict[str, Any]:
"""Return a valid access token for the Fitbit API."""
if not self._oauth_session.valid_token:
await self._oauth_session.async_ensure_token_valid()
return self._oauth_session.token
class ConfigFlowFitbitApi(FitbitApi):
"""Profile fitbit authentication before a ConfigEntry exists.
This implementation directly provides the token without supporting refresh.
"""
def __init__(
self,
hass: HomeAssistant,
token: dict[str, Any],
) -> None:
"""Initialize ConfigFlowFitbitApi."""
super().__init__(hass)
self._token = token
async def async_get_access_token(self) -> dict[str, Any]:
"""Return the token for the Fitbit API."""
return self._token