Skip to content

Commit cdaaa2b

Browse files
allenporterCopilot
andauthored
Update fitbit to use new asyncio client library for device list (home-assistant#157308)
Co-authored-by: Copilot <[email protected]>
1 parent bd84dac commit cdaaa2b

File tree

10 files changed

+111
-102
lines changed

10 files changed

+111
-102
lines changed

homeassistant/components/fitbit/api.py

Lines changed: 38 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,30 @@
11
"""API for fitbit bound to Home Assistant OAuth."""
22

33
from abc import ABC, abstractmethod
4-
from collections.abc import Callable
4+
from collections.abc import Awaitable, Callable
55
import logging
66
from typing import Any, cast
77

88
from fitbit import Fitbit
99
from fitbit.exceptions import HTTPException, HTTPUnauthorized
10+
from fitbit_web_api import ApiClient, Configuration, DevicesApi
11+
from fitbit_web_api.exceptions import (
12+
ApiException,
13+
OpenApiException,
14+
UnauthorizedException,
15+
)
16+
from fitbit_web_api.models.device import Device
1017
from requests.exceptions import ConnectionError as RequestsConnectionError
1118

1219
from homeassistant.const import CONF_ACCESS_TOKEN
1320
from homeassistant.core import HomeAssistant
1421
from homeassistant.helpers import config_entry_oauth2_flow
22+
from homeassistant.helpers.aiohttp_client import async_get_clientsession
1523
from homeassistant.util.unit_system import METRIC_SYSTEM
1624

1725
from .const import FitbitUnitSystem
1826
from .exceptions import FitbitApiException, FitbitAuthException
19-
from .model import FitbitDevice, FitbitProfile
27+
from .model import FitbitProfile
2028

2129
_LOGGER = logging.getLogger(__name__)
2230

@@ -58,6 +66,14 @@ async def _async_get_client(self) -> Fitbit:
5866
expires_at=float(token[CONF_EXPIRES_AT]),
5967
)
6068

69+
async def _async_get_fitbit_web_api(self) -> ApiClient:
70+
"""Create and return an ApiClient configured with the current access token."""
71+
token = await self.async_get_access_token()
72+
configuration = Configuration()
73+
configuration.pool_manager = async_get_clientsession(self._hass)
74+
configuration.access_token = token[CONF_ACCESS_TOKEN]
75+
return ApiClient(configuration)
76+
6177
async def async_get_user_profile(self) -> FitbitProfile:
6278
"""Return the user profile from the API."""
6379
if self._profile is None:
@@ -94,21 +110,13 @@ async def async_get_unit_system(self) -> FitbitUnitSystem:
94110
return FitbitUnitSystem.METRIC
95111
return FitbitUnitSystem.EN_US
96112

97-
async def async_get_devices(self) -> list[FitbitDevice]:
98-
"""Return available devices."""
99-
client = await self._async_get_client()
100-
devices: list[dict[str, str]] = await self._run(client.get_devices)
113+
async def async_get_devices(self) -> list[Device]:
114+
"""Return available devices using fitbit-web-api."""
115+
client = await self._async_get_fitbit_web_api()
116+
devices_api = DevicesApi(client)
117+
devices: list[Device] = await self._run_async(devices_api.get_devices)
101118
_LOGGER.debug("get_devices=%s", devices)
102-
return [
103-
FitbitDevice(
104-
id=device["id"],
105-
device_version=device["deviceVersion"],
106-
battery_level=int(device["batteryLevel"]),
107-
battery=device["battery"],
108-
type=device["type"],
109-
)
110-
for device in devices
111-
]
119+
return devices
112120

113121
async def async_get_latest_time_series(self, resource_type: str) -> dict[str, Any]:
114122
"""Return the most recent value from the time series for the specified resource type."""
@@ -140,6 +148,20 @@ async def _run[_T](self, func: Callable[[], _T]) -> _T:
140148
_LOGGER.debug("Error from fitbit API: %s", err)
141149
raise FitbitApiException("Error from fitbit API") from err
142150

151+
async def _run_async[_T](self, func: Callable[[], Awaitable[_T]]) -> _T:
152+
"""Run client command."""
153+
try:
154+
return await func()
155+
except UnauthorizedException as err:
156+
_LOGGER.debug("Unauthorized error from fitbit API: %s", err)
157+
raise FitbitAuthException("Authentication error from fitbit API") from err
158+
except ApiException as err:
159+
_LOGGER.debug("Error from fitbit API: %s", err)
160+
raise FitbitApiException("Error from fitbit API") from err
161+
except OpenApiException as err:
162+
_LOGGER.debug("Error communicating with fitbit API: %s", err)
163+
raise FitbitApiException("Communication error from fitbit API") from err
164+
143165

144166
class OAuthFitbitApi(FitbitApi):
145167
"""Provide fitbit authentication tied to an OAuth2 based config entry."""

homeassistant/components/fitbit/coordinator.py

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,14 +6,15 @@
66
import logging
77
from typing import Final
88

9+
from fitbit_web_api.models.device import Device
10+
911
from homeassistant.config_entries import ConfigEntry
1012
from homeassistant.core import HomeAssistant
1113
from homeassistant.exceptions import ConfigEntryAuthFailed
1214
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
1315

1416
from .api import FitbitApi
1517
from .exceptions import FitbitApiException, FitbitAuthException
16-
from .model import FitbitDevice
1718

1819
_LOGGER = logging.getLogger(__name__)
1920

@@ -23,7 +24,7 @@
2324
type FitbitConfigEntry = ConfigEntry[FitbitData]
2425

2526

26-
class FitbitDeviceCoordinator(DataUpdateCoordinator[dict[str, FitbitDevice]]):
27+
class FitbitDeviceCoordinator(DataUpdateCoordinator[dict[str, Device]]):
2728
"""Coordinator for fetching fitbit devices from the API."""
2829

2930
config_entry: FitbitConfigEntry
@@ -41,7 +42,7 @@ def __init__(
4142
)
4243
self._api = api
4344

44-
async def _async_update_data(self) -> dict[str, FitbitDevice]:
45+
async def _async_update_data(self) -> dict[str, Device]:
4546
"""Fetch data from API endpoint."""
4647
async with asyncio.timeout(TIMEOUT):
4748
try:
@@ -50,7 +51,7 @@ async def _async_update_data(self) -> dict[str, FitbitDevice]:
5051
raise ConfigEntryAuthFailed(err) from err
5152
except FitbitApiException as err:
5253
raise UpdateFailed(err) from err
53-
return {device.id: device for device in devices}
54+
return {device.id: device for device in devices if device.id is not None}
5455

5556

5657
@dataclass

homeassistant/components/fitbit/manifest.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,6 @@
66
"dependencies": ["application_credentials", "http"],
77
"documentation": "https://www.home-assistant.io/integrations/fitbit",
88
"iot_class": "cloud_polling",
9-
"loggers": ["fitbit"],
10-
"requirements": ["fitbit==0.3.1"]
9+
"loggers": ["fitbit", "fitbit_web_api"],
10+
"requirements": ["fitbit==0.3.1", "fitbit-web-api==2.13.5"]
1111
}

homeassistant/components/fitbit/model.py

Lines changed: 0 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -21,26 +21,6 @@ class FitbitProfile:
2121
"""The locale defined in the user's Fitbit account settings."""
2222

2323

24-
@dataclass
25-
class FitbitDevice:
26-
"""Device from the Fitbit API response."""
27-
28-
id: str
29-
"""The device ID."""
30-
31-
device_version: str
32-
"""The product name of the device."""
33-
34-
battery_level: int
35-
"""The battery level as a percentage."""
36-
37-
battery: str
38-
"""Returns the battery level of the device."""
39-
40-
type: str
41-
"""The type of the device such as TRACKER or SCALE."""
42-
43-
4424
@dataclass
4525
class FitbitConfig:
4626
"""Information from the fitbit ConfigEntry data."""

homeassistant/components/fitbit/sensor.py

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@
88
import logging
99
from typing import Any, Final, cast
1010

11+
from fitbit_web_api.models.device import Device
12+
1113
from homeassistant.components.sensor import (
1214
SensorDeviceClass,
1315
SensorEntity,
@@ -32,7 +34,7 @@
3234
from .const import ATTRIBUTION, BATTERY_LEVELS, DOMAIN, FitbitScope, FitbitUnitSystem
3335
from .coordinator import FitbitConfigEntry, FitbitDeviceCoordinator
3436
from .exceptions import FitbitApiException, FitbitAuthException
35-
from .model import FitbitDevice, config_from_entry_data
37+
from .model import config_from_entry_data
3638

3739
_LOGGER: Final = logging.getLogger(__name__)
3840

@@ -657,7 +659,7 @@ def __init__(
657659
coordinator: FitbitDeviceCoordinator,
658660
user_profile_id: str,
659661
description: FitbitSensorEntityDescription,
660-
device: FitbitDevice,
662+
device: Device,
661663
enable_default_override: bool,
662664
) -> None:
663665
"""Initialize the Fitbit sensor."""
@@ -677,7 +679,9 @@ def __init__(
677679
@property
678680
def icon(self) -> str | None:
679681
"""Icon to use in the frontend, if any."""
680-
if battery_level := BATTERY_LEVELS.get(self.device.battery):
682+
if self.device.battery is not None and (
683+
battery_level := BATTERY_LEVELS.get(self.device.battery)
684+
):
681685
return icon_for_battery_level(battery_level=battery_level)
682686
return self.entity_description.icon
683687

@@ -697,7 +701,7 @@ async def async_added_to_hass(self) -> None:
697701
@callback
698702
def _handle_coordinator_update(self) -> None:
699703
"""Handle updated data from the coordinator."""
700-
self.device = self.coordinator.data[self.device.id]
704+
self.device = self.coordinator.data[cast(str, self.device.id)]
701705
self._attr_native_value = self.device.battery
702706
self.async_write_ha_state()
703707

@@ -715,7 +719,7 @@ def __init__(
715719
coordinator: FitbitDeviceCoordinator,
716720
user_profile_id: str,
717721
description: FitbitSensorEntityDescription,
718-
device: FitbitDevice,
722+
device: Device,
719723
) -> None:
720724
"""Initialize the Fitbit sensor."""
721725
super().__init__(coordinator)
@@ -736,6 +740,6 @@ async def async_added_to_hass(self) -> None:
736740
@callback
737741
def _handle_coordinator_update(self) -> None:
738742
"""Handle updated data from the coordinator."""
739-
self.device = self.coordinator.data[self.device.id]
743+
self.device = self.coordinator.data[cast(str, self.device.id)]
740744
self._attr_native_value = self.device.battery_level
741745
self.async_write_ha_state()

requirements_all.txt

Lines changed: 3 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

requirements_test_all.txt

Lines changed: 3 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

tests/components/fitbit/conftest.py

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
from homeassistant.setup import async_setup_component
2121

2222
from tests.common import MockConfigEntry
23+
from tests.test_util.aiohttp import AiohttpClientMocker
2324

2425
CLIENT_ID = "1234"
2526
CLIENT_SECRET = "5678"
@@ -206,12 +207,13 @@ def mock_device_response() -> list[dict[str, Any]]:
206207

207208

208209
@pytest.fixture(autouse=True)
209-
def mock_devices(requests_mock: Mocker, devices_response: dict[str, Any]) -> None:
210+
def mock_devices(
211+
aioclient_mock: AiohttpClientMocker, devices_response: dict[str, Any]
212+
) -> None:
210213
"""Fixture to setup fake device responses."""
211-
requests_mock.register_uri(
212-
"GET",
214+
aioclient_mock.get(
213215
DEVICES_API_URL,
214-
status_code=HTTPStatus.OK,
216+
status=HTTPStatus.OK,
215217
json=devices_response,
216218
)
217219

tests/components/fitbit/test_init.py

Lines changed: 14 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,6 @@
44
from http import HTTPStatus
55

66
import pytest
7-
from requests_mock.mocker import Mocker
87

98
from homeassistant.components.fitbit.const import (
109
CONF_CLIENT_ID,
@@ -90,14 +89,18 @@ async def test_token_refresh_success(
9089
assert await integration_setup()
9190
assert config_entry.state is ConfigEntryState.LOADED
9291

93-
# Verify token request
94-
assert len(aioclient_mock.mock_calls) == 1
92+
# Verify token request and that the device API is called with new token
93+
assert len(aioclient_mock.mock_calls) == 2
9594
assert aioclient_mock.mock_calls[0][2] == {
9695
CONF_CLIENT_ID: CLIENT_ID,
9796
CONF_CLIENT_SECRET: CLIENT_SECRET,
9897
"grant_type": "refresh_token",
9998
"refresh_token": FAKE_REFRESH_TOKEN,
10099
}
100+
assert str(aioclient_mock.mock_calls[1][1]) == DEVICES_API_URL
101+
assert aioclient_mock.mock_calls[1][3].get("Authorization") == (
102+
"Bearer server-access-token"
103+
)
101104

102105
# Verify updated token
103106
assert (
@@ -144,15 +147,15 @@ async def test_device_update_coordinator_failure(
144147
integration_setup: Callable[[], Awaitable[bool]],
145148
config_entry: MockConfigEntry,
146149
setup_credentials: None,
147-
requests_mock: Mocker,
150+
aioclient_mock: AiohttpClientMocker,
148151
) -> None:
149152
"""Test case where the device update coordinator fails on the first request."""
150153
assert config_entry.state is ConfigEntryState.NOT_LOADED
151154

152-
requests_mock.register_uri(
153-
"GET",
155+
aioclient_mock.clear_requests()
156+
aioclient_mock.get(
154157
DEVICES_API_URL,
155-
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
158+
status=HTTPStatus.INTERNAL_SERVER_ERROR,
156159
)
157160

158161
assert not await integration_setup()
@@ -164,15 +167,15 @@ async def test_device_update_coordinator_reauth(
164167
integration_setup: Callable[[], Awaitable[bool]],
165168
config_entry: MockConfigEntry,
166169
setup_credentials: None,
167-
requests_mock: Mocker,
170+
aioclient_mock: AiohttpClientMocker,
168171
) -> None:
169172
"""Test case where the device update coordinator fails on the first request."""
170173
assert config_entry.state is ConfigEntryState.NOT_LOADED
171174

172-
requests_mock.register_uri(
173-
"GET",
175+
aioclient_mock.clear_requests()
176+
aioclient_mock.get(
174177
DEVICES_API_URL,
175-
status_code=HTTPStatus.UNAUTHORIZED,
178+
status=HTTPStatus.UNAUTHORIZED,
176179
json={
177180
"errors": [{"errorType": "invalid_grant"}],
178181
},

0 commit comments

Comments
 (0)