diff --git a/tests/components/fitbit/conftest.py b/tests/components/fitbit/conftest.py index e3e5bbd1d18..291951a745a 100644 --- a/tests/components/fitbit/conftest.py +++ b/tests/components/fitbit/conftest.py @@ -104,13 +104,19 @@ async def mock_sensor_platform_setup( @pytest.fixture(name="profile_id") -async def mock_profile_id() -> str: +def mock_profile_id() -> str: """Fixture for the profile id returned from the API response.""" return PROFILE_USER_ID +@pytest.fixture(name="profile_locale") +def mock_profile_locale() -> str: + """Fixture to set the API response for the user profile.""" + return "en_US" + + @pytest.fixture(name="profile", autouse=True) -async def mock_profile(requests_mock: Mocker, profile_id: str) -> None: +def mock_profile(requests_mock: Mocker, profile_id: str, profile_locale: str) -> None: """Fixture to setup fake requests made to Fitbit API during config flow.""" requests_mock.register_uri( "GET", @@ -120,20 +126,20 @@ async def mock_profile(requests_mock: Mocker, profile_id: str) -> None: "user": { "encodedId": profile_id, "fullName": "My name", - "locale": "en_US", + "locale": profile_locale, }, }, ) @pytest.fixture(name="devices_response") -async def mock_device_response() -> list[dict[str, Any]]: +def mock_device_response() -> list[dict[str, Any]]: """Return the list of devices.""" return [] @pytest.fixture(autouse=True) -async def mock_devices(requests_mock: Mocker, devices_response: dict[str, Any]) -> None: +def mock_devices(requests_mock: Mocker, devices_response: dict[str, Any]) -> None: """Fixture to setup fake device responses.""" requests_mock.register_uri( "GET", @@ -151,7 +157,7 @@ def timeseries_response(resource: str, value: str) -> dict[str, Any]: @pytest.fixture(name="register_timeseries") -async def mock_register_timeseries( +def mock_register_timeseries( requests_mock: Mocker, ) -> Callable[[str, dict[str, Any]], None]: """Fixture to setup fake timeseries API responses.""" diff --git a/tests/components/fitbit/snapshots/test_sensor.ambr b/tests/components/fitbit/snapshots/test_sensor.ambr new file mode 100644 index 00000000000..719a2f8a6b8 --- /dev/null +++ b/tests/components/fitbit/snapshots/test_sensor.ambr @@ -0,0 +1,280 @@ +# serializer version: 1 +# name: test_sensors[monitored_resources0-sensor.activity_calories-activities/activityCalories-135] + tuple( + '135', + ReadOnlyDict({ + 'attribution': 'Data provided by Fitbit.com', + 'friendly_name': 'Activity Calories', + 'icon': 'mdi:fire', + 'unit_of_measurement': 'cal', + }), + 'fitbit-api-user-id-1_activities/activityCalories', + ) +# --- +# name: test_sensors[monitored_resources1-sensor.calories-activities/calories-139] + tuple( + '139', + ReadOnlyDict({ + 'attribution': 'Data provided by Fitbit.com', + 'friendly_name': 'Calories', + 'icon': 'mdi:fire', + 'unit_of_measurement': 'cal', + }), + 'fitbit-api-user-id-1_activities/calories', + ) +# --- +# name: test_sensors[monitored_resources10-sensor.steps-activities/steps-5600] + tuple( + '5600', + ReadOnlyDict({ + 'attribution': 'Data provided by Fitbit.com', + 'friendly_name': 'Steps', + 'icon': 'mdi:walk', + 'unit_of_measurement': 'steps', + }), + 'fitbit-api-user-id-1_activities/steps', + ) +# --- +# name: test_sensors[monitored_resources11-sensor.weight-body/weight-175] + tuple( + '175.0', + ReadOnlyDict({ + 'attribution': 'Data provided by Fitbit.com', + 'device_class': 'weight', + 'friendly_name': 'Weight', + 'icon': 'mdi:human', + 'state_class': , + 'unit_of_measurement': , + }), + 'fitbit-api-user-id-1_body/weight', + ) +# --- +# name: test_sensors[monitored_resources12-sensor.body_fat-body/fat-18] + tuple( + '18.0', + ReadOnlyDict({ + 'attribution': 'Data provided by Fitbit.com', + 'friendly_name': 'Body Fat', + 'icon': 'mdi:human', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'fitbit-api-user-id-1_body/fat', + ) +# --- +# name: test_sensors[monitored_resources13-sensor.bmi-body/bmi-23.7] + tuple( + '23.7', + ReadOnlyDict({ + 'attribution': 'Data provided by Fitbit.com', + 'friendly_name': 'BMI', + 'icon': 'mdi:human', + 'state_class': , + 'unit_of_measurement': 'BMI', + }), + 'fitbit-api-user-id-1_body/bmi', + ) +# --- +# name: test_sensors[monitored_resources14-sensor.awakenings_count-sleep/awakeningsCount-7] + tuple( + '7', + ReadOnlyDict({ + 'attribution': 'Data provided by Fitbit.com', + 'friendly_name': 'Awakenings Count', + 'icon': 'mdi:sleep', + 'unit_of_measurement': 'times awaken', + }), + 'fitbit-api-user-id-1_sleep/awakeningsCount', + ) +# --- +# name: test_sensors[monitored_resources15-sensor.sleep_efficiency-sleep/efficiency-80] + tuple( + '80', + ReadOnlyDict({ + 'attribution': 'Data provided by Fitbit.com', + 'friendly_name': 'Sleep Efficiency', + 'icon': 'mdi:sleep', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'fitbit-api-user-id-1_sleep/efficiency', + ) +# --- +# name: test_sensors[monitored_resources16-sensor.minutes_after_wakeup-sleep/minutesAfterWakeup-17] + tuple( + '17', + ReadOnlyDict({ + 'attribution': 'Data provided by Fitbit.com', + 'device_class': 'duration', + 'friendly_name': 'Minutes After Wakeup', + 'icon': 'mdi:sleep', + 'unit_of_measurement': , + }), + 'fitbit-api-user-id-1_sleep/minutesAfterWakeup', + ) +# --- +# name: test_sensors[monitored_resources17-sensor.sleep_minutes_asleep-sleep/minutesAsleep-360] + tuple( + '360', + ReadOnlyDict({ + 'attribution': 'Data provided by Fitbit.com', + 'device_class': 'duration', + 'friendly_name': 'Sleep Minutes Asleep', + 'icon': 'mdi:sleep', + 'unit_of_measurement': , + }), + 'fitbit-api-user-id-1_sleep/minutesAsleep', + ) +# --- +# name: test_sensors[monitored_resources18-sensor.sleep_minutes_awake-sleep/minutesAwake-35] + tuple( + '35', + ReadOnlyDict({ + 'attribution': 'Data provided by Fitbit.com', + 'device_class': 'duration', + 'friendly_name': 'Sleep Minutes Awake', + 'icon': 'mdi:sleep', + 'unit_of_measurement': , + }), + 'fitbit-api-user-id-1_sleep/minutesAwake', + ) +# --- +# name: test_sensors[monitored_resources19-sensor.sleep_minutes_to_fall_asleep-sleep/minutesToFallAsleep-35] + tuple( + '35', + ReadOnlyDict({ + 'attribution': 'Data provided by Fitbit.com', + 'device_class': 'duration', + 'friendly_name': 'Sleep Minutes to Fall Asleep', + 'icon': 'mdi:sleep', + 'unit_of_measurement': , + }), + 'fitbit-api-user-id-1_sleep/minutesToFallAsleep', + ) +# --- +# name: test_sensors[monitored_resources2-sensor.distance-activities/distance-12.7] + tuple( + '12.70', + ReadOnlyDict({ + 'attribution': 'Data provided by Fitbit.com', + 'device_class': 'distance', + 'friendly_name': 'Distance', + 'icon': 'mdi:map-marker', + 'unit_of_measurement': , + }), + 'fitbit-api-user-id-1_activities/distance', + ) +# --- +# name: test_sensors[monitored_resources20-sensor.sleep_start_time-sleep/startTime-2020-01-27T00:17:30.000] + tuple( + '2020-01-27T00:17:30.000', + ReadOnlyDict({ + 'attribution': 'Data provided by Fitbit.com', + 'friendly_name': 'Sleep Start Time', + 'icon': 'mdi:clock', + }), + 'fitbit-api-user-id-1_sleep/startTime', + ) +# --- +# name: test_sensors[monitored_resources21-sensor.sleep_time_in_bed-sleep/timeInBed-462] + tuple( + '462', + ReadOnlyDict({ + 'attribution': 'Data provided by Fitbit.com', + 'device_class': 'duration', + 'friendly_name': 'Sleep Time in Bed', + 'icon': 'mdi:hotel', + 'unit_of_measurement': , + }), + 'fitbit-api-user-id-1_sleep/timeInBed', + ) +# --- +# name: test_sensors[monitored_resources3-sensor.elevation-activities/elevation-7600.24] + tuple( + '7600.24', + ReadOnlyDict({ + 'attribution': 'Data provided by Fitbit.com', + 'device_class': 'distance', + 'friendly_name': 'Elevation', + 'icon': 'mdi:walk', + 'unit_of_measurement': , + }), + 'fitbit-api-user-id-1_activities/elevation', + ) +# --- +# name: test_sensors[monitored_resources4-sensor.floors-activities/floors-8] + tuple( + '8', + ReadOnlyDict({ + 'attribution': 'Data provided by Fitbit.com', + 'friendly_name': 'Floors', + 'icon': 'mdi:walk', + 'unit_of_measurement': 'floors', + }), + 'fitbit-api-user-id-1_activities/floors', + ) +# --- +# name: test_sensors[monitored_resources5-sensor.resting_heart_rate-activities/heart-api_value5] + tuple( + '76', + ReadOnlyDict({ + 'attribution': 'Data provided by Fitbit.com', + 'friendly_name': 'Resting Heart Rate', + 'icon': 'mdi:heart-pulse', + 'unit_of_measurement': 'bpm', + }), + 'fitbit-api-user-id-1_activities/heart', + ) +# --- +# name: test_sensors[monitored_resources6-sensor.minutes_fairly_active-activities/minutesFairlyActive-35] + tuple( + '35', + ReadOnlyDict({ + 'attribution': 'Data provided by Fitbit.com', + 'device_class': 'duration', + 'friendly_name': 'Minutes Fairly Active', + 'icon': 'mdi:walk', + 'unit_of_measurement': , + }), + 'fitbit-api-user-id-1_activities/minutesFairlyActive', + ) +# --- +# name: test_sensors[monitored_resources7-sensor.minutes_lightly_active-activities/minutesLightlyActive-95] + tuple( + '95', + ReadOnlyDict({ + 'attribution': 'Data provided by Fitbit.com', + 'device_class': 'duration', + 'friendly_name': 'Minutes Lightly Active', + 'icon': 'mdi:walk', + 'unit_of_measurement': , + }), + 'fitbit-api-user-id-1_activities/minutesLightlyActive', + ) +# --- +# name: test_sensors[monitored_resources8-sensor.minutes_sedentary-activities/minutesSedentary-18] + tuple( + '18', + ReadOnlyDict({ + 'attribution': 'Data provided by Fitbit.com', + 'device_class': 'duration', + 'friendly_name': 'Minutes Sedentary', + 'icon': 'mdi:seat-recline-normal', + 'unit_of_measurement': , + }), + 'fitbit-api-user-id-1_activities/minutesSedentary', + ) +# --- +# name: test_sensors[monitored_resources9-sensor.minutes_very_active-activities/minutesVeryActive-20] + tuple( + '20', + ReadOnlyDict({ + 'attribution': 'Data provided by Fitbit.com', + 'device_class': 'duration', + 'friendly_name': 'Minutes Very Active', + 'icon': 'mdi:run', + 'unit_of_measurement': , + }), + 'fitbit-api-user-id-1_activities/minutesVeryActive', + ) +# --- diff --git a/tests/components/fitbit/test_sensor.py b/tests/components/fitbit/test_sensor.py index 6918a712f72..7351f919380 100644 --- a/tests/components/fitbit/test_sensor.py +++ b/tests/components/fitbit/test_sensor.py @@ -5,10 +5,12 @@ from collections.abc import Awaitable, Callable from typing import Any import pytest +from syrupy.assertion import SnapshotAssertion from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er -from .conftest import timeseries_response +from .conftest import PROFILE_USER_ID, timeseries_response DEVICE_RESPONSE_CHARGE_2 = { "battery": "Medium", @@ -31,30 +33,169 @@ DEVICE_RESPONSE_ARIA_AIR = { @pytest.mark.parametrize( - "monitored_resources", - [["activities/steps"]], + ( + "monitored_resources", + "entity_id", + "api_resource", + "api_value", + ), + [ + ( + ["activities/activityCalories"], + "sensor.activity_calories", + "activities/activityCalories", + "135", + ), + ( + ["activities/calories"], + "sensor.calories", + "activities/calories", + "139", + ), + ( + ["activities/distance"], + "sensor.distance", + "activities/distance", + "12.7", + ), + ( + ["activities/elevation"], + "sensor.elevation", + "activities/elevation", + "7600.24", + ), + ( + ["activities/floors"], + "sensor.floors", + "activities/floors", + "8", + ), + ( + ["activities/heart"], + "sensor.resting_heart_rate", + "activities/heart", + {"restingHeartRate": 76}, + ), + ( + ["activities/minutesFairlyActive"], + "sensor.minutes_fairly_active", + "activities/minutesFairlyActive", + 35, + ), + ( + ["activities/minutesLightlyActive"], + "sensor.minutes_lightly_active", + "activities/minutesLightlyActive", + 95, + ), + ( + ["activities/minutesSedentary"], + "sensor.minutes_sedentary", + "activities/minutesSedentary", + 18, + ), + ( + ["activities/minutesVeryActive"], + "sensor.minutes_very_active", + "activities/minutesVeryActive", + 20, + ), + ( + ["activities/steps"], + "sensor.steps", + "activities/steps", + "5600", + ), + ( + ["body/weight"], + "sensor.weight", + "body/weight", + "175", + ), + ( + ["body/fat"], + "sensor.body_fat", + "body/fat", + "18", + ), + ( + ["body/bmi"], + "sensor.bmi", + "body/bmi", + "23.7", + ), + ( + ["sleep/awakeningsCount"], + "sensor.awakenings_count", + "sleep/awakeningsCount", + "7", + ), + ( + ["sleep/efficiency"], + "sensor.sleep_efficiency", + "sleep/efficiency", + "80", + ), + ( + ["sleep/minutesAfterWakeup"], + "sensor.minutes_after_wakeup", + "sleep/minutesAfterWakeup", + "17", + ), + ( + ["sleep/minutesAsleep"], + "sensor.sleep_minutes_asleep", + "sleep/minutesAsleep", + "360", + ), + ( + ["sleep/minutesAwake"], + "sensor.sleep_minutes_awake", + "sleep/minutesAwake", + "35", + ), + ( + ["sleep/minutesToFallAsleep"], + "sensor.sleep_minutes_to_fall_asleep", + "sleep/minutesToFallAsleep", + "35", + ), + ( + ["sleep/startTime"], + "sensor.sleep_start_time", + "sleep/startTime", + "2020-01-27T00:17:30.000", + ), + ( + ["sleep/timeInBed"], + "sensor.sleep_time_in_bed", + "sleep/timeInBed", + "462", + ), + ], ) -async def test_step_sensor( +async def test_sensors( hass: HomeAssistant, sensor_platform_setup: Callable[[], Awaitable[bool]], register_timeseries: Callable[[str, dict[str, Any]], None], + entity_registry: er.EntityRegistry, + entity_id: str, + api_resource: str, + api_value: str, + snapshot: SnapshotAssertion, ) -> None: - """Test battery level sensor.""" + """Test sensors.""" register_timeseries( - "activities/steps", timeseries_response("activities-steps", "5600") + api_resource, timeseries_response(api_resource.replace("/", "-"), api_value) ) await sensor_platform_setup() - state = hass.states.get("sensor.steps") + state = hass.states.get(entity_id) assert state - assert state.state == "5600" - assert state.attributes == { - "attribution": "Data provided by Fitbit.com", - "friendly_name": "Steps", - "icon": "mdi:walk", - "unit_of_measurement": "steps", - } + entry = entity_registry.async_get(entity_id) + assert entry + assert (state.state, state.attributes, entry.unique_id) == snapshot @pytest.mark.parametrize( @@ -64,6 +205,7 @@ async def test_step_sensor( async def test_device_battery_level( hass: HomeAssistant, sensor_platform_setup: Callable[[], Awaitable[bool]], + entity_registry: er.EntityRegistry, ) -> None: """Test battery level sensor for devices.""" @@ -80,6 +222,10 @@ async def test_device_battery_level( "type": "tracker", } + entry = entity_registry.async_get("sensor.charge_2_battery") + assert entry + assert entry.unique_id == f"{PROFILE_USER_ID}_devices/battery_816713257" + state = hass.states.get("sensor.aria_air_battery") assert state assert state.state == "High" @@ -90,3 +236,81 @@ async def test_device_battery_level( "model": "Aria Air", "type": "scale", } + + entity_registry = er.async_get(hass) + entry = entity_registry.async_get("sensor.aria_air_battery") + assert entry + assert entry.unique_id == f"{PROFILE_USER_ID}_devices/battery_016713257" + + +@pytest.mark.parametrize( + ("monitored_resources", "profile_locale", "expected_unit"), + [ + (["body/weight"], "en_US", "kg"), + (["body/weight"], "en_GB", "st"), + (["body/weight"], "es_ES", "kg"), + ], +) +async def test_profile_local( + hass: HomeAssistant, + sensor_platform_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() + + state = hass.states.get("sensor.weight") + assert state + assert state.attributes.get("unit_of_measurement") == expected_unit + + +@pytest.mark.parametrize( + ("sensor_platform_config", "api_response", "expected_state"), + [ + ( + {"clock_format": "12H", "monitored_resources": ["sleep/startTime"]}, + "17:05", + "5:05 PM", + ), + ( + {"clock_format": "12H", "monitored_resources": ["sleep/startTime"]}, + "5:05", + "5:05 AM", + ), + ( + {"clock_format": "12H", "monitored_resources": ["sleep/startTime"]}, + "00:05", + "12:05 AM", + ), + ( + {"clock_format": "24H", "monitored_resources": ["sleep/startTime"]}, + "17:05", + "17:05", + ), + ( + {"clock_format": "12H", "monitored_resources": ["sleep/startTime"]}, + "", + "-", + ), + ], +) +async def test_sleep_time_clock_format( + hass: HomeAssistant, + sensor_platform_setup: Callable[[], Awaitable[bool]], + register_timeseries: Callable[[str, dict[str, Any]], None], + api_response: str, + expected_state: str, +) -> None: + """Test the clock format configuration.""" + + register_timeseries( + "sleep/startTime", timeseries_response("sleep-startTime", api_response) + ) + await sensor_platform_setup() + + state = hass.states.get("sensor.sleep_start_time") + assert state + assert state.state == expected_state