diff --git a/homeassistant/components/fitbit/sensor.py b/homeassistant/components/fitbit/sensor.py index ab9a593e195..2218454bd61 100644 --- a/homeassistant/components/fitbit/sensor.py +++ b/homeassistant/components/fitbit/sensor.py @@ -6,30 +6,16 @@ from collections.abc import Callable from dataclasses import dataclass import datetime import logging -import os from typing import Any, Final, cast -from fitbit import Fitbit -from oauthlib.oauth2.rfc6749.errors import OAuth2Error -import voluptuous as vol - -from homeassistant.components.application_credentials import ( - ClientCredential, - async_import_client_credential, -) from homeassistant.components.sensor import ( - PLATFORM_SCHEMA as SENSOR_PLATFORM_SCHEMA, SensorDeviceClass, SensorEntity, SensorEntityDescription, SensorStateClass, ) -from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry +from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( - CONF_CLIENT_ID, - CONF_CLIENT_SECRET, - CONF_TOKEN, - CONF_UNIT_SYSTEM, PERCENTAGE, EntityCategory, UnitOfLength, @@ -38,33 +24,13 @@ from homeassistant.const import ( UnitOfVolume, ) from homeassistant.core import HomeAssistant, callback -from homeassistant.data_entry_flow import FlowResultType -import homeassistant.helpers.config_validation as cv from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.icon import icon_for_battery_level -from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.helpers.update_coordinator import CoordinatorEntity -from homeassistant.util.json import load_json_object from .api import FitbitApi -from .const import ( - ATTR_ACCESS_TOKEN, - ATTR_LAST_SAVED_AT, - ATTR_REFRESH_TOKEN, - ATTRIBUTION, - BATTERY_LEVELS, - CONF_CLOCK_FORMAT, - CONF_MONITORED_RESOURCES, - DEFAULT_CLOCK_FORMAT, - DEFAULT_CONFIG, - DOMAIN, - FITBIT_CONFIG_FILE, - FITBIT_DEFAULT_RESOURCES, - FitbitScope, - FitbitUnitSystem, -) +from .const import ATTRIBUTION, BATTERY_LEVELS, DOMAIN, FitbitScope, FitbitUnitSystem from .coordinator import FitbitData, FitbitDeviceCoordinator from .exceptions import FitbitApiException, FitbitAuthException from .model import FitbitDevice, config_from_entry_data @@ -533,126 +499,6 @@ FITBIT_RESOURCE_BATTERY_LEVEL = FitbitSensorEntityDescription( native_unit_of_measurement=PERCENTAGE, ) -FITBIT_RESOURCES_KEYS: Final[list[str]] = [ - desc.key - for desc in (*FITBIT_RESOURCES_LIST, FITBIT_RESOURCE_BATTERY, SLEEP_START_TIME) -] - -PLATFORM_SCHEMA: Final = SENSOR_PLATFORM_SCHEMA.extend( - { - vol.Optional( - CONF_MONITORED_RESOURCES, default=FITBIT_DEFAULT_RESOURCES - ): vol.All(cv.ensure_list, [vol.In(FITBIT_RESOURCES_KEYS)]), - vol.Optional(CONF_CLOCK_FORMAT, default=DEFAULT_CLOCK_FORMAT): vol.In( - ["12H", "24H"] - ), - vol.Optional(CONF_UNIT_SYSTEM, default=FitbitUnitSystem.LEGACY_DEFAULT): vol.In( - [ - FitbitUnitSystem.EN_GB, - FitbitUnitSystem.EN_US, - FitbitUnitSystem.METRIC, - FitbitUnitSystem.LEGACY_DEFAULT, - ] - ), - } -) - -# Only import configuration if it was previously created successfully with all -# of the following fields. -FITBIT_CONF_KEYS = [ - CONF_CLIENT_ID, - CONF_CLIENT_SECRET, - ATTR_ACCESS_TOKEN, - ATTR_REFRESH_TOKEN, - ATTR_LAST_SAVED_AT, -] - - -def load_config_file(config_path: str) -> dict[str, Any] | None: - """Load existing valid fitbit.conf from disk for import.""" - if os.path.isfile(config_path): - config_file = load_json_object(config_path) - if config_file != DEFAULT_CONFIG and all( - key in config_file for key in FITBIT_CONF_KEYS - ): - return config_file - return None - - -async def async_setup_platform( - hass: HomeAssistant, - config: ConfigType, - add_entities: AddEntitiesCallback, - discovery_info: DiscoveryInfoType | None = None, -) -> None: - """Set up the Fitbit sensor.""" - config_path = hass.config.path(FITBIT_CONFIG_FILE) - config_file = await hass.async_add_executor_job(load_config_file, config_path) - _LOGGER.debug("loaded config file: %s", config_file) - - if config_file is not None: - _LOGGER.debug("Importing existing fitbit.conf application credentials") - - # Refresh the token before importing to ensure it is working and not - # expired on first initialization. - authd_client = Fitbit( - config_file[CONF_CLIENT_ID], - config_file[CONF_CLIENT_SECRET], - access_token=config_file[ATTR_ACCESS_TOKEN], - refresh_token=config_file[ATTR_REFRESH_TOKEN], - expires_at=config_file[ATTR_LAST_SAVED_AT], - refresh_cb=lambda x: None, - ) - try: - updated_token = await hass.async_add_executor_job( - authd_client.client.refresh_token - ) - except OAuth2Error as err: - _LOGGER.debug("Unable to import fitbit OAuth2 credentials: %s", err) - translation_key = "deprecated_yaml_import_issue_cannot_connect" - else: - await async_import_client_credential( - hass, - DOMAIN, - ClientCredential( - config_file[CONF_CLIENT_ID], config_file[CONF_CLIENT_SECRET] - ), - ) - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_IMPORT}, - data={ - "auth_implementation": DOMAIN, - CONF_TOKEN: { - ATTR_ACCESS_TOKEN: updated_token[ATTR_ACCESS_TOKEN], - ATTR_REFRESH_TOKEN: updated_token[ATTR_REFRESH_TOKEN], - "expires_at": updated_token["expires_at"], - "scope": " ".join(updated_token.get("scope", [])), - }, - CONF_CLOCK_FORMAT: config[CONF_CLOCK_FORMAT], - CONF_UNIT_SYSTEM: config[CONF_UNIT_SYSTEM], - CONF_MONITORED_RESOURCES: config[CONF_MONITORED_RESOURCES], - }, - ) - translation_key = "deprecated_yaml_import" - if ( - result.get("type") == FlowResultType.ABORT - and result.get("reason") == "cannot_connect" - ): - translation_key = "deprecated_yaml_import_issue_cannot_connect" - else: - translation_key = "deprecated_yaml_no_import" - - async_create_issue( - hass, - DOMAIN, - "deprecated_yaml", - breaks_in_ha_version="2024.5.0", - is_fixable=False, - severity=IssueSeverity.WARNING, - translation_key=translation_key, - ) - async def async_setup_entry( hass: HomeAssistant, diff --git a/homeassistant/components/fitbit/strings.json b/homeassistant/components/fitbit/strings.json index e1ca1b01f7a..2df6fa14b07 100644 --- a/homeassistant/components/fitbit/strings.json +++ b/homeassistant/components/fitbit/strings.json @@ -40,19 +40,5 @@ "name": "Battery level" } } - }, - "issues": { - "deprecated_yaml_no_import": { - "title": "Fitbit YAML configuration is being removed", - "description": "Configuring Fitbit using YAML is being removed.\n\nRemove the `fitbit` configuration from your configuration.yaml file and remove fitbit.conf if it exists and restart Home Assistant and [set up the integration](/config/integrations/dashboard/add?domain=fitbit) manually." - }, - "deprecated_yaml_import": { - "title": "Fitbit YAML configuration is being removed", - "description": "Configuring Fitbit using YAML is being removed.\n\nYour existing YAML configuration has been imported into the UI automatically, including OAuth Application Credentials.\n\nRemove the `fitbit` configuration from your configuration.yaml file and remove fitbit.conf and restart Home Assistant to fix this issue." - }, - "deprecated_yaml_import_issue_cannot_connect": { - "title": "The Fitbit YAML configuration import failed", - "description": "Configuring Fitbit using YAML is being removed but there was a connection error importing your YAML configuration.\n\nRestart Home Assistant to try again or remove the Fitbit YAML configuration from your configuration.yaml file and remove the fitbit.conf and continue to [set up the integration](/config/integrations/dashboard/add?domain=fitbit) manually." - } } } diff --git a/tests/components/fitbit/conftest.py b/tests/components/fitbit/conftest.py index 57511739993..48ceca02d0e 100644 --- a/tests/components/fitbit/conftest.py +++ b/tests/components/fitbit/conftest.py @@ -1,6 +1,6 @@ """Test fixtures for fitbit.""" -from collections.abc import Awaitable, Callable, Generator +from collections.abc import Awaitable, Callable import datetime from http import HTTPStatus import time @@ -14,12 +14,7 @@ from homeassistant.components.application_credentials import ( ClientCredential, async_import_client_credential, ) -from homeassistant.components.fitbit.const import ( - CONF_CLIENT_ID, - CONF_CLIENT_SECRET, - DOMAIN, - OAUTH_SCOPES, -) +from homeassistant.components.fitbit.const import DOMAIN, OAUTH_SCOPES from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component @@ -83,13 +78,16 @@ def mock_token_entry(token_expiration_time: float, scopes: list[str]) -> dict[st @pytest.fixture(name="config_entry") -def mock_config_entry(token_entry: dict[str, Any]) -> MockConfigEntry: +def mock_config_entry( + token_entry: dict[str, Any], imported_config_data: dict[str, Any] +) -> MockConfigEntry: """Fixture for a config entry.""" return MockConfigEntry( domain=DOMAIN, data={ "auth_implementation": FAKE_AUTH_IMPL, "token": token_entry, + **imported_config_data, }, unique_id=PROFILE_USER_ID, ) @@ -107,37 +105,6 @@ async def setup_credentials(hass: HomeAssistant) -> None: ) -@pytest.fixture(name="fitbit_config_yaml") -def mock_fitbit_config_yaml(token_expiration_time: float) -> dict[str, Any] | None: - """Fixture for the yaml fitbit.conf file contents.""" - return { - CONF_CLIENT_ID: CLIENT_ID, - CONF_CLIENT_SECRET: CLIENT_SECRET, - "access_token": FAKE_ACCESS_TOKEN, - "refresh_token": FAKE_REFRESH_TOKEN, - "last_saved_at": token_expiration_time, - } - - -@pytest.fixture(name="fitbit_config_setup") -def mock_fitbit_config_setup( - fitbit_config_yaml: dict[str, Any] | None, -) -> Generator[None]: - """Fixture to mock out fitbit.conf file data loading and persistence.""" - has_config = fitbit_config_yaml is not None - with ( - patch( - "homeassistant.components.fitbit.sensor.os.path.isfile", - return_value=has_config, - ), - patch( - "homeassistant.components.fitbit.sensor.load_json_object", - return_value=fitbit_config_yaml, - ), - ): - yield - - @pytest.fixture(name="monitored_resources") def mock_monitored_resources() -> list[str] | None: """Fixture for the fitbit yaml config monitored_resources field.""" @@ -150,8 +117,8 @@ def mock_configured_unit_syststem() -> str | None: return None -@pytest.fixture(name="sensor_platform_config") -def mock_sensor_platform_config( +@pytest.fixture(name="imported_config_data") +def mock_imported_config_data( monitored_resources: list[str] | None, configured_unit_system: str | None, ) -> dict[str, Any]: @@ -164,32 +131,6 @@ def mock_sensor_platform_config( return config -@pytest.fixture(name="sensor_platform_setup") -async def mock_sensor_platform_setup( - hass: HomeAssistant, - sensor_platform_config: dict[str, Any], -) -> Callable[[], Awaitable[bool]]: - """Fixture to set up the integration.""" - - async def run() -> bool: - result = await async_setup_component( - hass, - "sensor", - { - "sensor": [ - { - "platform": DOMAIN, - **sensor_platform_config, - } - ] - }, - ) - await hass.async_block_till_done() - return result - - return run - - @pytest.fixture def platforms() -> list[Platform]: """Fixture to specify platforms to test.""" diff --git a/tests/components/fitbit/test_config_flow.py b/tests/components/fitbit/test_config_flow.py index 6f717459486..70c54cd2657 100644 --- a/tests/components/fitbit/test_config_flow.py +++ b/tests/components/fitbit/test_config_flow.py @@ -2,7 +2,6 @@ from collections.abc import Awaitable, Callable from http import HTTPStatus -import time from typing import Any from unittest.mock import patch @@ -13,7 +12,7 @@ from homeassistant import config_entries from homeassistant.components.fitbit.const import DOMAIN, OAUTH2_AUTHORIZE, OAUTH2_TOKEN from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType -from homeassistant.helpers import config_entry_oauth2_flow, issue_registry as ir +from homeassistant.helpers import config_entry_oauth2_flow from .conftest import ( CLIENT_ID, @@ -255,207 +254,6 @@ async def test_config_entry_already_exists( assert result.get("reason") == "already_configured" -@pytest.mark.parametrize( - "token_expiration_time", - [time.time() + 86400, time.time() - 86400], - ids=("token_active", "token_expired"), -) -async def test_import_fitbit_config( - hass: HomeAssistant, - fitbit_config_setup: None, - sensor_platform_setup: Callable[[], Awaitable[bool]], - issue_registry: ir.IssueRegistry, - requests_mock: Mocker, -) -> None: - """Test that platform configuration is imported successfully.""" - - requests_mock.register_uri( - "POST", - OAUTH2_TOKEN, - status_code=HTTPStatus.OK, - json=SERVER_ACCESS_TOKEN, - ) - - with patch( - "homeassistant.components.fitbit.async_setup_entry", return_value=True - ) as mock_setup: - await sensor_platform_setup() - - assert len(mock_setup.mock_calls) == 1 - entries = hass.config_entries.async_entries(DOMAIN) - assert len(entries) == 1 - - # Verify valid profile can be fetched from the API - config_entry = entries[0] - assert config_entry.title == DISPLAY_NAME - assert config_entry.unique_id == PROFILE_USER_ID - - data = dict(config_entry.data) - # Verify imported values from fitbit.conf and configuration.yaml and - # that the token is updated. - assert "token" in data - expires_at = data["token"]["expires_at"] - assert expires_at > time.time() - del data["token"]["expires_at"] - assert dict(config_entry.data) == { - "auth_implementation": DOMAIN, - "clock_format": "24H", - "monitored_resources": ["activities/steps"], - "token": { - "access_token": "server-access-token", - "refresh_token": "server-refresh-token", - "scope": "activity heartrate nutrition profile settings sleep weight", - }, - "unit_system": "default", - } - - # Verify an issue is raised for deprecated configuration.yaml - issue = issue_registry.issues.get((DOMAIN, "deprecated_yaml")) - assert issue - assert issue.translation_key == "deprecated_yaml_import" - - -async def test_import_fitbit_config_failure_cannot_connect( - hass: HomeAssistant, - fitbit_config_setup: None, - sensor_platform_setup: Callable[[], Awaitable[bool]], - issue_registry: ir.IssueRegistry, - requests_mock: Mocker, -) -> None: - """Test platform configuration fails to import successfully.""" - - requests_mock.register_uri( - "POST", - OAUTH2_TOKEN, - status_code=HTTPStatus.OK, - json=SERVER_ACCESS_TOKEN, - ) - requests_mock.register_uri( - "GET", PROFILE_API_URL, status_code=HTTPStatus.INTERNAL_SERVER_ERROR - ) - - with patch( - "homeassistant.components.fitbit.async_setup_entry", return_value=True - ) as mock_setup: - await sensor_platform_setup() - - assert len(mock_setup.mock_calls) == 0 - - # Verify an issue is raised that we were unable to import configuration - issue = issue_registry.issues.get((DOMAIN, "deprecated_yaml")) - assert issue - assert issue.translation_key == "deprecated_yaml_import_issue_cannot_connect" - - -@pytest.mark.parametrize( - "status_code", - [ - (HTTPStatus.UNAUTHORIZED), - (HTTPStatus.INTERNAL_SERVER_ERROR), - ], -) -async def test_import_fitbit_config_cannot_refresh( - hass: HomeAssistant, - fitbit_config_setup: None, - sensor_platform_setup: Callable[[], Awaitable[bool]], - issue_registry: ir.IssueRegistry, - requests_mock: Mocker, - status_code: HTTPStatus, -) -> None: - """Test platform configuration import fails when refreshing the token.""" - - requests_mock.register_uri( - "POST", - OAUTH2_TOKEN, - status_code=status_code, - json="", - ) - - with patch( - "homeassistant.components.fitbit.async_setup_entry", return_value=True - ) as mock_setup: - await sensor_platform_setup() - - assert len(mock_setup.mock_calls) == 0 - - # Verify an issue is raised that we were unable to import configuration - issue = issue_registry.issues.get((DOMAIN, "deprecated_yaml")) - assert issue - assert issue.translation_key == "deprecated_yaml_import_issue_cannot_connect" - - -async def test_import_fitbit_config_already_exists( - hass: HomeAssistant, - config_entry: MockConfigEntry, - setup_credentials: None, - integration_setup: Callable[[], Awaitable[bool]], - fitbit_config_setup: None, - sensor_platform_setup: Callable[[], Awaitable[bool]], - issue_registry: ir.IssueRegistry, - requests_mock: Mocker, -) -> None: - """Test that platform configuration is not imported if it already exists.""" - - requests_mock.register_uri( - "POST", - OAUTH2_TOKEN, - status_code=HTTPStatus.OK, - json=SERVER_ACCESS_TOKEN, - ) - - # Verify existing config entry - entries = hass.config_entries.async_entries(DOMAIN) - assert len(entries) == 1 - - with patch( - "homeassistant.components.fitbit.async_setup_entry", return_value=True - ) as mock_config_entry_setup: - await integration_setup() - - assert len(mock_config_entry_setup.mock_calls) == 1 - - with patch( - "homeassistant.components.fitbit.async_setup_entry", return_value=True - ) as mock_import_setup: - await sensor_platform_setup() - - assert len(mock_import_setup.mock_calls) == 0 - - # Still one config entry - entries = hass.config_entries.async_entries(DOMAIN) - assert len(entries) == 1 - - # Verify an issue is raised for deprecated configuration.yaml - issue = issue_registry.issues.get((DOMAIN, "deprecated_yaml")) - assert issue - assert issue.translation_key == "deprecated_yaml_import" - - -async def test_platform_setup_without_import( - hass: HomeAssistant, - sensor_platform_setup: Callable[[], Awaitable[bool]], - issue_registry: ir.IssueRegistry, -) -> None: - """Test platform configuration.yaml but no existing fitbit.conf credentials.""" - - with patch( - "homeassistant.components.fitbit.async_setup_entry", return_value=True - ) as mock_setup: - await sensor_platform_setup() - - # Verify no configuration entry is imported since the integration is not - # fully setup properly - assert len(mock_setup.mock_calls) == 0 - entries = hass.config_entries.async_entries(DOMAIN) - assert len(entries) == 0 - - # Verify an issue is raised for deprecated configuration.yaml - assert len(issue_registry.issues) == 1 - issue = issue_registry.issues.get((DOMAIN, "deprecated_yaml")) - assert issue - assert issue.translation_key == "deprecated_yaml_no_import" - - @pytest.mark.usefixtures("current_request_with_host") async def test_reauth_flow( hass: HomeAssistant, diff --git a/tests/components/fitbit/test_sensor.py b/tests/components/fitbit/test_sensor.py index 9443d0500eb..d67bd75396f 100644 --- a/tests/components/fitbit/test_sensor.py +++ b/tests/components/fitbit/test_sensor.py @@ -212,8 +212,8 @@ def mock_token_refresh(requests_mock: Mocker) -> None: ) async def test_sensors( hass: HomeAssistant, - fitbit_config_setup: None, - sensor_platform_setup: Callable[[], Awaitable[bool]], + setup_credentials: None, + integration_setup: Callable[[], Awaitable[bool]], register_timeseries: Callable[[str, dict[str, Any]], None], entity_registry: er.EntityRegistry, entity_id: str, @@ -226,7 +226,7 @@ async def test_sensors( register_timeseries( api_resource, timeseries_response(api_resource.replace("/", "-"), api_value) ) - await sensor_platform_setup() + await integration_setup() entries = hass.config_entries.async_entries(DOMAIN) assert len(entries) == 1 @@ -243,13 +243,13 @@ async def test_sensors( ) async def test_device_battery( hass: HomeAssistant, - fitbit_config_setup: None, - sensor_platform_setup: Callable[[], Awaitable[bool]], + setup_credentials: None, + integration_setup: Callable[[], Awaitable[bool]], entity_registry: er.EntityRegistry, ) -> None: """Test battery level sensor for devices.""" - assert await sensor_platform_setup() + assert await integration_setup() entries = hass.config_entries.async_entries(DOMAIN) assert len(entries) == 1 @@ -290,13 +290,13 @@ async def test_device_battery( ) async def test_device_battery_level( hass: HomeAssistant, - fitbit_config_setup: None, - sensor_platform_setup: Callable[[], Awaitable[bool]], + setup_credentials: None, + integration_setup: Callable[[], Awaitable[bool]], entity_registry: er.EntityRegistry, ) -> None: """Test battery level sensor for devices.""" - assert await sensor_platform_setup() + assert await integration_setup() entries = hass.config_entries.async_entries(DOMAIN) assert len(entries) == 1 @@ -347,15 +347,15 @@ async def test_device_battery_level( ) async def test_profile_local( hass: HomeAssistant, - fitbit_config_setup: None, - sensor_platform_setup: Callable[[], Awaitable[bool]], + setup_credentials: None, + integration_setup: Callable[[], Awaitable[bool]], register_timeseries: Callable[[str, dict[str, Any]], None], expected_unit: str, ) -> None: """Test the fitbit profile locale impact on unit of measure.""" register_timeseries("body/weight", timeseries_response("body-weight", "175")) - await sensor_platform_setup() + await integration_setup() entries = hass.config_entries.async_entries(DOMAIN) assert len(entries) == 1 @@ -365,7 +365,7 @@ async def test_profile_local( @pytest.mark.parametrize( - ("sensor_platform_config", "api_response", "expected_state"), + ("imported_config_data", "api_response", "expected_state"), [ ( {"clock_format": "12H", "monitored_resources": ["sleep/startTime"]}, @@ -396,8 +396,8 @@ async def test_profile_local( ) async def test_sleep_time_clock_format( hass: HomeAssistant, - fitbit_config_setup: None, - sensor_platform_setup: Callable[[], Awaitable[bool]], + setup_credentials: None, + integration_setup: Callable[[], Awaitable[bool]], register_timeseries: Callable[[str, dict[str, Any]], None], api_response: str, expected_state: str, @@ -407,7 +407,7 @@ async def test_sleep_time_clock_format( register_timeseries( "sleep/startTime", timeseries_response("sleep-startTime", api_response) ) - await sensor_platform_setup() + assert await integration_setup() state = hass.states.get("sensor.sleep_start_time") assert state