From d05164112995a77cbcba65f731d6e150fafa8d30 Mon Sep 17 00:00:00 2001 From: Carmen Alvarez Date: Thu, 18 May 2023 11:41:36 +0200 Subject: [PATCH 1/3] Support parsing "classic" sleep logs --- tests/data/fitbit_sleep_response_classic.json | 145 ++++++++++++++++++ tests/services/fitbit/test_parser.py | 11 ++ withingsslack/services/fitbit/parser.py | 52 +++++-- 3 files changed, 198 insertions(+), 10 deletions(-) create mode 100644 tests/data/fitbit_sleep_response_classic.json diff --git a/tests/data/fitbit_sleep_response_classic.json b/tests/data/fitbit_sleep_response_classic.json new file mode 100644 index 00000000..075eee84 --- /dev/null +++ b/tests/data/fitbit_sleep_response_classic.json @@ -0,0 +1,145 @@ +{ + "sleep": [ + { + "dateOfSleep": "2023-05-17", + "duration": 27120000, + "efficiency": 97, + "endTime": "2023-05-17T08:07:30.000", + "infoCode": 1, + "isMainSleep": true, + "levels": { + "data": [ + { + "dateTime": "2023-05-17T00:35:00.000", + "level": "awake", + "seconds": 120 + }, + { + "dateTime": "2023-05-17T00:37:00.000", + "level": "restless", + "seconds": 60 + }, + { + "dateTime": "2023-05-17T00:38:00.000", + "level": "asleep", + "seconds": 9900 + }, + { + "dateTime": "2023-05-17T03:23:00.000", + "level": "restless", + "seconds": 120 + }, + { + "dateTime": "2023-05-17T03:25:00.000", + "level": "asleep", + "seconds": 120 + }, + { + "dateTime": "2023-05-17T03:27:00.000", + "level": "restless", + "seconds": 60 + }, + { + "dateTime": "2023-05-17T03:28:00.000", + "level": "asleep", + "seconds": 2280 + }, + { + "dateTime": "2023-05-17T04:06:00.000", + "level": "restless", + "seconds": 60 + }, + { + "dateTime": "2023-05-17T04:07:00.000", + "level": "asleep", + "seconds": 2820 + }, + { + "dateTime": "2023-05-17T04:54:00.000", + "level": "restless", + "seconds": 60 + }, + { + "dateTime": "2023-05-17T04:55:00.000", + "level": "asleep", + "seconds": 4320 + }, + { + "dateTime": "2023-05-17T06:07:00.000", + "level": "restless", + "seconds": 60 + }, + { + "dateTime": "2023-05-17T06:08:00.000", + "level": "asleep", + "seconds": 900 + }, + { + "dateTime": "2023-05-17T06:23:00.000", + "level": "restless", + "seconds": 120 + }, + { + "dateTime": "2023-05-17T06:25:00.000", + "level": "asleep", + "seconds": 360 + }, + { + "dateTime": "2023-05-17T06:31:00.000", + "level": "restless", + "seconds": 60 + }, + { + "dateTime": "2023-05-17T06:32:00.000", + "level": "asleep", + "seconds": 180 + }, + { + "dateTime": "2023-05-17T06:35:00.000", + "level": "restless", + "seconds": 60 + }, + { + "dateTime": "2023-05-17T06:36:00.000", + "level": "asleep", + "seconds": 5460 + } + ], + "summary": { + "asleep": { + "count": 0, + "minutes": 439 + }, + "awake": { + "count": 1, + "minutes": 2 + }, + "restless": { + "count": 9, + "minutes": 11 + } + } + }, + "logId": 41366101052, + "logType": "auto_detected", + "minutesAfterWakeup": 0, + "minutesAsleep": 439, + "minutesAwake": 13, + "minutesToFallAsleep": 0, + "startTime": "2023-05-17T00:35:00.000", + "timeInBed": 452, + "type": "classic" + } + ], + "summary": { + "stages": { + "deep": 0, + "light": 0, + "rem": 0, + "wake": 0 + }, + "totalMinutesAsleep": 439, + "totalSleepRecords": 1, + "totalTimeInBed": 452 + } +} diff --git a/tests/services/fitbit/test_parser.py b/tests/services/fitbit/test_parser.py index bfbfbb47..61b769ab 100644 --- a/tests/services/fitbit/test_parser.py +++ b/tests/services/fitbit/test_parser.py @@ -32,6 +32,17 @@ slack_alias="somebody", ), ), + ( + "fitbit_sleep_response_classic.json", + SleepData( + start_time=datetime.datetime(2023, 5, 17, 0, 35, 0), + end_time=datetime.datetime(2023, 5, 17, 8, 7, 30), + sleep_minutes=439, + wake_minutes=2, + score=97, + slack_alias="somebody", + ), + ), ("fitbit_sleep_response_no_main_sleep_item.json", None), ], ) diff --git a/withingsslack/services/fitbit/parser.py b/withingsslack/services/fitbit/parser.py index 8e0f588a..69c9e42a 100644 --- a/withingsslack/services/fitbit/parser.py +++ b/withingsslack/services/fitbit/parser.py @@ -1,7 +1,7 @@ import json -from typing import Optional, Self +from typing import Annotated, Literal, Optional, Self, Union from withingsslack.services import models as svc_models -from pydantic import BaseModel +from pydantic import BaseModel, Field import datetime @@ -9,12 +9,21 @@ class FitbitSleepItemSummaryItem(BaseModel): minutes: int -class FitbitSleepItemSummary(BaseModel): +class FitbitClassicSleepItemSummary(BaseModel): + awake: FitbitSleepItemSummaryItem + asleep: FitbitSleepItemSummaryItem + + +class FitbitStagesSleepItemSummary(BaseModel): wake: FitbitSleepItemSummaryItem -class FitbitSleepItemLevels(BaseModel): - summary: FitbitSleepItemSummary +class FitbitStagesSleepItemLevels(BaseModel): + summary: FitbitStagesSleepItemSummary + + +class FitbitClassicSleepItemLevels(BaseModel): + summary: FitbitClassicSleepItemSummary class FitbitSleepItem(BaseModel): @@ -23,11 +32,25 @@ class FitbitSleepItem(BaseModel): endTime: str isMainSleep: bool startTime: str - levels: FitbitSleepItemLevels + + +class FitbitClassicSleepItem(FitbitSleepItem): + type: Literal["classic"] + levels: FitbitClassicSleepItemLevels + + +class FitbitStagesSleepItem(FitbitSleepItem): + type: Literal["stages"] + levels: FitbitStagesSleepItemLevels class FitbitSleep(BaseModel): - sleep: list[FitbitSleepItem] + sleep: list[ + Annotated[ + Union[FitbitClassicSleepItem, FitbitStagesSleepItem], + Field(discriminator="type"), + ] + ] @classmethod def parse(cls, input: str) -> Self: @@ -45,14 +68,23 @@ def parse_sleep(input: str, slack_alias: str) -> Optional[svc_models.SleepData]: if not main_sleep_item: return None + wake_minutes = ( + main_sleep_item.levels.summary.awake.minutes + if main_sleep_item.type == "classic" + else main_sleep_item.levels.summary.wake.minutes + ) + asleep_minutes = ( + main_sleep_item.levels.summary.asleep.minutes + if main_sleep_item.type == "classic" + else main_sleep_item.duration / 60000 - wake_minutes + ) return svc_models.SleepData( start_time=datetime.datetime.strptime( main_sleep_item.startTime, DATETIME_FORMAT ), end_time=datetime.datetime.strptime(main_sleep_item.endTime, DATETIME_FORMAT), score=main_sleep_item.efficiency, - sleep_minutes=main_sleep_item.duration / 60000 - - main_sleep_item.levels.summary.wake.minutes, - wake_minutes=main_sleep_item.levels.summary.wake.minutes, + sleep_minutes=asleep_minutes, + wake_minutes=wake_minutes, slack_alias=slack_alias, ) From 58509b922177d95350b20156da963fc0c989b62d Mon Sep 17 00:00:00 2001 From: Carmen Alvarez Date: Thu, 18 May 2023 12:29:32 +0200 Subject: [PATCH 2/3] Simplify support of classic sleep logs Looks like we don't need to specify a `discriminator` attribute after all? --- withingsslack/services/fitbit/parser.py | 31 ++++++------------------- 1 file changed, 7 insertions(+), 24 deletions(-) diff --git a/withingsslack/services/fitbit/parser.py b/withingsslack/services/fitbit/parser.py index 69c9e42a..1c9072ee 100644 --- a/withingsslack/services/fitbit/parser.py +++ b/withingsslack/services/fitbit/parser.py @@ -1,7 +1,7 @@ import json -from typing import Annotated, Literal, Optional, Self, Union +from typing import Optional, Self from withingsslack.services import models as svc_models -from pydantic import BaseModel, Field +from pydantic import BaseModel import datetime @@ -18,12 +18,8 @@ class FitbitStagesSleepItemSummary(BaseModel): wake: FitbitSleepItemSummaryItem -class FitbitStagesSleepItemLevels(BaseModel): - summary: FitbitStagesSleepItemSummary - - -class FitbitClassicSleepItemLevels(BaseModel): - summary: FitbitClassicSleepItemSummary +class FitbitSleepItemLevels(BaseModel): + summary: FitbitClassicSleepItemSummary | FitbitStagesSleepItemSummary class FitbitSleepItem(BaseModel): @@ -32,25 +28,12 @@ class FitbitSleepItem(BaseModel): endTime: str isMainSleep: bool startTime: str - - -class FitbitClassicSleepItem(FitbitSleepItem): - type: Literal["classic"] - levels: FitbitClassicSleepItemLevels - - -class FitbitStagesSleepItem(FitbitSleepItem): - type: Literal["stages"] - levels: FitbitStagesSleepItemLevels + levels: FitbitSleepItemLevels + type: str class FitbitSleep(BaseModel): - sleep: list[ - Annotated[ - Union[FitbitClassicSleepItem, FitbitStagesSleepItem], - Field(discriminator="type"), - ] - ] + sleep: list[FitbitSleepItem] @classmethod def parse(cls, input: str) -> Self: From 8003b25980b325109a2b81e320e82010d0497cb9 Mon Sep 17 00:00:00 2001 From: Carmen Alvarez Date: Thu, 18 May 2023 12:43:46 +0200 Subject: [PATCH 3/3] Revert "Simplify support of classic sleep logs" This reverts commit 58509b922177d95350b20156da963fc0c989b62d. We need to use the `discriminator` to validate the json. Otherwise we'll incorrectly parse invalid data with `type`=`stages` and `summary` containing `asleep`. --- withingsslack/services/fitbit/parser.py | 31 +++++++++++++++++++------ 1 file changed, 24 insertions(+), 7 deletions(-) diff --git a/withingsslack/services/fitbit/parser.py b/withingsslack/services/fitbit/parser.py index 1c9072ee..69c9e42a 100644 --- a/withingsslack/services/fitbit/parser.py +++ b/withingsslack/services/fitbit/parser.py @@ -1,7 +1,7 @@ import json -from typing import Optional, Self +from typing import Annotated, Literal, Optional, Self, Union from withingsslack.services import models as svc_models -from pydantic import BaseModel +from pydantic import BaseModel, Field import datetime @@ -18,8 +18,12 @@ class FitbitStagesSleepItemSummary(BaseModel): wake: FitbitSleepItemSummaryItem -class FitbitSleepItemLevels(BaseModel): - summary: FitbitClassicSleepItemSummary | FitbitStagesSleepItemSummary +class FitbitStagesSleepItemLevels(BaseModel): + summary: FitbitStagesSleepItemSummary + + +class FitbitClassicSleepItemLevels(BaseModel): + summary: FitbitClassicSleepItemSummary class FitbitSleepItem(BaseModel): @@ -28,12 +32,25 @@ class FitbitSleepItem(BaseModel): endTime: str isMainSleep: bool startTime: str - levels: FitbitSleepItemLevels - type: str + + +class FitbitClassicSleepItem(FitbitSleepItem): + type: Literal["classic"] + levels: FitbitClassicSleepItemLevels + + +class FitbitStagesSleepItem(FitbitSleepItem): + type: Literal["stages"] + levels: FitbitStagesSleepItemLevels class FitbitSleep(BaseModel): - sleep: list[FitbitSleepItem] + sleep: list[ + Annotated[ + Union[FitbitClassicSleepItem, FitbitStagesSleepItem], + Field(discriminator="type"), + ] + ] @classmethod def parse(cls, input: str) -> Self: