From 9600c7fac1486d2ce3262ece5c1c43f9aee400d7 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Tue, 24 Oct 2023 16:38:11 +0200 Subject: [PATCH] Add workout calendar to Withings (#102589) --- homeassistant/components/withings/__init__.py | 4 +- homeassistant/components/withings/calendar.py | 104 +++++++++++ .../components/withings/strings.json | 5 + .../withings/snapshots/test_calendar.ambr | 167 ++++++++++++++++++ tests/components/withings/test_calendar.py | 85 +++++++++ 5 files changed, 364 insertions(+), 1 deletion(-) create mode 100644 homeassistant/components/withings/calendar.py create mode 100644 tests/components/withings/snapshots/test_calendar.ambr create mode 100644 tests/components/withings/test_calendar.py diff --git a/homeassistant/components/withings/__init__.py b/homeassistant/components/withings/__init__.py index 2158b169844..496aba290ba 100644 --- a/homeassistant/components/withings/__init__.py +++ b/homeassistant/components/withings/__init__.py @@ -62,7 +62,7 @@ from .coordinator import ( WithingsWorkoutDataUpdateCoordinator, ) -PLATFORMS = [Platform.BINARY_SENSOR, Platform.SENSOR] +PLATFORMS = [Platform.BINARY_SENSOR, Platform.CALENDAR, Platform.SENSOR] CONFIG_SCHEMA = vol.Schema( { @@ -129,6 +129,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: class WithingsData: """Dataclass to hold withings domain data.""" + client: WithingsClient measurement_coordinator: WithingsMeasurementDataUpdateCoordinator sleep_coordinator: WithingsSleepDataUpdateCoordinator bed_presence_coordinator: WithingsBedPresenceDataUpdateCoordinator @@ -174,6 +175,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: client.refresh_token_function = _refresh_token withings_data = WithingsData( + client=client, measurement_coordinator=WithingsMeasurementDataUpdateCoordinator(hass, client), sleep_coordinator=WithingsSleepDataUpdateCoordinator(hass, client), bed_presence_coordinator=WithingsBedPresenceDataUpdateCoordinator(hass, client), diff --git a/homeassistant/components/withings/calendar.py b/homeassistant/components/withings/calendar.py new file mode 100644 index 00000000000..3ee2c7dae59 --- /dev/null +++ b/homeassistant/components/withings/calendar.py @@ -0,0 +1,104 @@ +"""Calendar platform for Withings.""" +from __future__ import annotations + +from collections.abc import Callable +from datetime import datetime + +from aiowithings import WithingsClient, WorkoutCategory + +from homeassistant.components.calendar import CalendarEntity, CalendarEvent +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback +import homeassistant.helpers.entity_registry as er + +from . import DOMAIN, WithingsData +from .coordinator import WithingsWorkoutDataUpdateCoordinator +from .entity import WithingsEntity + + +async def async_setup_entry( + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the calendar platform for entity.""" + ent_reg = er.async_get(hass) + withings_data: WithingsData = hass.data[DOMAIN][entry.entry_id] + + workout_coordinator = withings_data.workout_coordinator + + calendar_setup_before = ent_reg.async_get_entity_id( + Platform.CALENDAR, + DOMAIN, + f"withings_{entry.unique_id}_workout", + ) + + if workout_coordinator.data is not None or calendar_setup_before: + async_add_entities( + [WithingsWorkoutCalendarEntity(withings_data.client, workout_coordinator)], + ) + else: + remove_calendar_listener: Callable[[], None] + + def _async_add_calendar_entity() -> None: + """Add calendar entity.""" + if workout_coordinator.data is not None: + async_add_entities( + [ + WithingsWorkoutCalendarEntity( + withings_data.client, workout_coordinator + ) + ], + ) + remove_calendar_listener() + + remove_calendar_listener = workout_coordinator.async_add_listener( + _async_add_calendar_entity + ) + + +def get_event_name(category: WorkoutCategory) -> str: + """Return human-readable category.""" + name = category.name.lower().capitalize() + return name.replace("_", " ") + + +class WithingsWorkoutCalendarEntity(CalendarEntity, WithingsEntity): + """A calendar entity.""" + + _attr_translation_key = "workout" + + coordinator: WithingsWorkoutDataUpdateCoordinator + + def __init__( + self, client: WithingsClient, coordinator: WithingsWorkoutDataUpdateCoordinator + ) -> None: + """Create the Calendar entity.""" + super().__init__(coordinator, "workout") + self.client = client + + @property + def event(self) -> CalendarEvent | None: + """Return the next upcoming event.""" + return None + + async def async_get_events( + self, hass: HomeAssistant, start_date: datetime, end_date: datetime + ) -> list[CalendarEvent]: + """Get all events in a specific time frame.""" + workouts = await self.client.get_workouts_in_period( + start_date.date(), end_date.date() + ) + event_list = [] + for workout in workouts: + event = CalendarEvent( + start=workout.start_date, + end=workout.end_date, + summary=get_event_name(workout.category), + ) + + event_list.append(event) + + return event_list diff --git a/homeassistant/components/withings/strings.json b/homeassistant/components/withings/strings.json index dcb63f22a2e..fb447f3578e 100644 --- a/homeassistant/components/withings/strings.json +++ b/homeassistant/components/withings/strings.json @@ -29,6 +29,11 @@ "name": "In bed" } }, + "calendar": { + "workout": { + "name": "Workouts" + } + }, "sensor": { "fat_mass": { "name": "Fat mass" diff --git a/tests/components/withings/snapshots/test_calendar.ambr b/tests/components/withings/snapshots/test_calendar.ambr new file mode 100644 index 00000000000..045b4216a2f --- /dev/null +++ b/tests/components/withings/snapshots/test_calendar.ambr @@ -0,0 +1,167 @@ +# serializer version: 1 +# name: test_api_calendar + list([ + dict({ + 'entity_id': 'calendar.henk_workouts', + 'name': 'henk Workouts', + }), + ]) +# --- +# name: test_api_events + list([ + dict({ + 'description': None, + 'end': dict({ + 'dateTime': '2023-08-29T12:15:13-07:00', + }), + 'location': None, + 'recurrence_id': None, + 'rrule': None, + 'start': dict({ + 'dateTime': '2023-08-29T12:06:51-07:00', + }), + 'summary': 'Walk', + 'uid': None, + }), + dict({ + 'description': None, + 'end': dict({ + 'dateTime': '2023-08-31T01:18:44-07:00', + }), + 'location': None, + 'recurrence_id': None, + 'rrule': None, + 'start': dict({ + 'dateTime': '2023-08-31T01:08:27-07:00', + }), + 'summary': 'Walk', + 'uid': None, + }), + dict({ + 'description': None, + 'end': dict({ + 'dateTime': '2023-08-04T09:15:19-07:00', + }), + 'location': None, + 'recurrence_id': None, + 'rrule': None, + 'start': dict({ + 'dateTime': '2023-08-04T09:00:39-07:00', + }), + 'summary': 'Walk', + 'uid': None, + }), + dict({ + 'description': None, + 'end': dict({ + 'dateTime': '2023-09-22T16:51:01-07:00', + }), + 'location': None, + 'recurrence_id': None, + 'rrule': None, + 'start': dict({ + 'dateTime': '2023-09-22T16:33:55-07:00', + }), + 'summary': 'Walk', + 'uid': None, + }), + dict({ + 'description': None, + 'end': dict({ + 'dateTime': '2023-09-14T11:31:46-07:00', + }), + 'location': None, + 'recurrence_id': None, + 'rrule': None, + 'start': dict({ + 'dateTime': '2023-09-14T11:20:49-07:00', + }), + 'summary': 'Walk', + 'uid': None, + }), + dict({ + 'description': None, + 'end': dict({ + 'dateTime': '2023-09-22T16:58:13-07:00', + }), + 'location': None, + 'recurrence_id': None, + 'rrule': None, + 'start': dict({ + 'dateTime': '2023-09-22T16:55:53-07:00', + }), + 'summary': 'Walk', + 'uid': None, + }), + dict({ + 'description': None, + 'end': dict({ + 'dateTime': '2023-09-14T11:15:27-07:00', + }), + 'location': None, + 'recurrence_id': None, + 'rrule': None, + 'start': dict({ + 'dateTime': '2023-09-14T10:42:31-07:00', + }), + 'summary': 'Walk', + 'uid': None, + }), + dict({ + 'description': None, + 'end': dict({ + 'dateTime': '2023-10-09T00:16:07-07:00', + }), + 'location': None, + 'recurrence_id': None, + 'rrule': None, + 'start': dict({ + 'dateTime': '2023-10-09T00:12:49-07:00', + }), + 'summary': 'Walk', + 'uid': None, + }), + dict({ + 'description': None, + 'end': dict({ + 'dateTime': '2023-10-09T02:43:58-07:00', + }), + 'location': None, + 'recurrence_id': None, + 'rrule': None, + 'start': dict({ + 'dateTime': '2023-10-09T02:39:43-07:00', + }), + 'summary': 'Walk', + 'uid': None, + }), + dict({ + 'description': None, + 'end': dict({ + 'dateTime': '2023-10-09T02:17:12-07:00', + }), + 'location': None, + 'recurrence_id': None, + 'rrule': None, + 'start': dict({ + 'dateTime': '2023-10-09T02:13:23-07:00', + }), + 'summary': 'Walk', + 'uid': None, + }), + dict({ + 'description': None, + 'end': dict({ + 'dateTime': '2023-10-09T02:17:12-07:00', + }), + 'location': None, + 'recurrence_id': None, + 'rrule': None, + 'start': dict({ + 'dateTime': '2023-10-09T02:13:23-07:00', + }), + 'summary': 'Walk', + 'uid': None, + }), + ]) +# --- diff --git a/tests/components/withings/test_calendar.py b/tests/components/withings/test_calendar.py new file mode 100644 index 00000000000..227f65473fc --- /dev/null +++ b/tests/components/withings/test_calendar.py @@ -0,0 +1,85 @@ +"""Tests for the Withings calendar.""" +from datetime import date, timedelta +from http import HTTPStatus +from unittest.mock import AsyncMock + +from freezegun.api import FrozenDateTimeFactory +from syrupy.assertion import SnapshotAssertion + +from homeassistant.core import HomeAssistant + +from . import load_workout_fixture + +from tests.common import MockConfigEntry, async_fire_time_changed +from tests.components.withings import setup_integration +from tests.typing import ClientSessionGenerator + + +async def test_api_calendar( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + withings: AsyncMock, + polling_config_entry: MockConfigEntry, + hass_client: ClientSessionGenerator, +) -> None: + """Test the API returns the calendar.""" + await setup_integration(hass, polling_config_entry, False) + + client = await hass_client() + response = await client.get("/api/calendars") + assert response.status == HTTPStatus.OK + data = await response.json() + assert data == snapshot + + +async def test_api_events( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + withings: AsyncMock, + polling_config_entry: MockConfigEntry, + hass_client: ClientSessionGenerator, +) -> None: + """Test the Withings calendar view.""" + await setup_integration(hass, polling_config_entry, False) + + client = await hass_client() + response = await client.get( + "/api/calendars/calendar.henk_workouts?start=2023-08-01&end=2023-11-01" + ) + assert withings.get_workouts_in_period.called == 1 + assert withings.get_workouts_in_period.call_args_list[1].args == ( + date(2023, 8, 1), + date(2023, 11, 1), + ) + assert response.status == HTTPStatus.OK + events = await response.json() + assert events == snapshot + + +async def test_calendar_created_when_workouts_available( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + withings: AsyncMock, + polling_config_entry: MockConfigEntry, + hass_client: ClientSessionGenerator, + freezer: FrozenDateTimeFactory, +) -> None: + """Test the calendar is only created when workouts are available.""" + withings.get_workouts_in_period.return_value = [] + await setup_integration(hass, polling_config_entry, False) + + assert hass.states.get("calendar.henk_workouts") is None + + freezer.tick(timedelta(minutes=10)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert hass.states.get("calendar.henk_workouts") is None + + withings.get_workouts_in_period.return_value = load_workout_fixture() + + freezer.tick(timedelta(minutes=10)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert hass.states.get("calendar.henk_workouts")