├── .github └── FUNDING.yml ├── .gitignore ├── README.md ├── custom_components └── oura │ ├── __init__.py │ ├── api.py │ ├── const.py │ ├── helpers │ ├── date_helper.py │ └── hass_helper.py │ ├── manifest.json │ ├── sensor.py │ ├── sensor_activity.py │ ├── sensor_base.py │ ├── sensor_base_dated.py │ ├── sensor_base_dated_series.py │ ├── sensor_bedtime.py │ ├── sensor_heart_rate.py │ ├── sensor_readiness.py │ ├── sensor_sessions.py │ ├── sensor_sleep.py │ ├── sensor_sleep_periods.py │ ├── sensor_sleep_score.py │ └── sensor_workouts.py └── docs └── img ├── apex-charts-scores.png └── apex-charts-sleep-trend.png /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | custom: ['buymeacoff.ee/nitobuendia'] 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | 2 | .DS_Store 3 | *.pyc 4 | .vscode/* 5 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Oura - Custom Component for Home-Assisant 2 | 3 | This project is a custom component for [Home-Assistant](https://home-assistant.io). 4 | 5 | The component sensors with sleep data for previous days from [Oura Ring](https://ouraring.com/). 6 | 7 | 8 | ## Sponsoring 9 | 10 | If this is helpful, feel free to `Buy Me a Beer`; or check other options on the Github `❤️ Sponsor` link on the top of this page. 11 | 12 | Buy Me A Coffee 13 | 14 | 15 | ## Table of Contents 16 | 17 | - [Oura - Custom Component for Home-Assisant](#oura---custom-component-for-home-assisant) 18 | - [Sponsoring](#sponsoring) 19 | - [Table of Contents](#table-of-contents) 20 | - [Installation](#installation) 21 | - [Configuration](#configuration) 22 | - [Schema](#schema) 23 | - [Parameters](#parameters) 24 | - [Top level parameters](#top-level-parameters) 25 | - [Sensors parameters](#sensors-parameters) 26 | - [Individual sensor parameters](#individual-sensor-parameters) 27 | - [Example](#example) 28 | - [How to get personal Oura token](#how-to-get-personal-oura-token) 29 | - [Sensors](#sensors) 30 | - [Common attributes](#common-attributes) 31 | - [Monitored days](#monitored-days) 32 | - [Backfilling](#backfilling) 33 | - [What is Backfilling and why it is needed](#what-is-backfilling-and-why-it-is-needed) 34 | - [Rule of thumb](#rule-of-thumb) 35 | - [Full backfilling logic](#full-backfilling-logic) 36 | - [Activity Sensor](#activity-sensor) 37 | - [Activity Sensor state](#activity-sensor-state) 38 | - [Activity Sensor monitored attributes](#activity-sensor-monitored-attributes) 39 | - [Activity Sensor sample output](#activity-sensor-sample-output) 40 | - [Heart Rate Sensor](#heart-rate-sensor) 41 | - [Heart Rate Sensor state](#heart-rate-sensor-state) 42 | - [Heart Rate Sensor monitored attributes](#heart-rate-sensor-monitored-attributes) 43 | - [Heart Rate Sensor sample output](#heart-rate-sensor-sample-output) 44 | - [Bedtime Sensor](#bedtime-sensor) 45 | - [Bedtime Sensor state](#bedtime-sensor-state) 46 | - [Bedtime Sensor monitored attributes](#bedtime-sensor-monitored-attributes) 47 | - [Bedtime Sensor sample output](#bedtime-sensor-sample-output) 48 | - [Readiness Sensor](#readiness-sensor) 49 | - [Readiness Sensor state](#readiness-sensor-state) 50 | - [Readiness Sensor monitored attributes](#readiness-sensor-monitored-attributes) 51 | - [Readiness Sensor sample output](#readiness-sensor-sample-output) 52 | - [Sessions Sensor](#sessions-sensor) 53 | - [Sessions Sensor state](#sessions-sensor-state) 54 | - [Sessions Sensor monitored attributes](#sessions-sensor-monitored-attributes) 55 | - [Sessions Sensor sample output](#sessions-sensor-sample-output) 56 | - [Sleep Sensor](#sleep-sensor) 57 | - [Sleep Sensor State](#sleep-sensor-state) 58 | - [Sleep Sensor monitored attributes](#sleep-sensor-monitored-attributes) 59 | - [Sleep Sensor sample output](#sleep-sensor-sample-output) 60 | - [Sleep Periods Sensor](#sleep-periods-sensor) 61 | - [Sleep Periods Sensor State](#sleep-periods-sensor-state) 62 | - [Sleep Periods Sensor monitored attributes](#sleep-periods-sensor-monitored-attributes) 63 | - [Sleep Periods Sensor sample output](#sleep-periods-sensor-sample-output) 64 | - [Sleep Score Sensor](#sleep-score-sensor) 65 | - [Sleep Score Sensor State](#sleep-score-sensor-state) 66 | - [Sleep Score Sensor monitored attributes](#sleep-score-sensor-monitored-attributes) 67 | - [Sleep Score Sensor sample output](#sleep-score-sensor-sample-output) 68 | - [Workouts Sensor](#workouts-sensor) 69 | - [Workouts Sensor state](#workouts-sensor-state) 70 | - [Workouts Sensor monitored attributes](#workouts-sensor-monitored-attributes) 71 | - [Workouts Sensor sample output](#workouts-sensor-sample-output) 72 | - [Derived sensors](#derived-sensors) 73 | - [Sensors and Lovelace](#sensors-and-lovelace) 74 | - [Custom Cards](#custom-cards) 75 | - [Using apexcharts-card](#using-apexcharts-card) 76 | - [Score card using apexcharts-card](#score-card-using-apexcharts-card) 77 | - [Sleep trend card using apexcharts-card](#sleep-trend-card-using-apexcharts-card) 78 | - [Frequently Asked Questions (FAQs) and Common Issues](#frequently-asked-questions-faqs-and-common-issues) 79 | 80 | ## Installation 81 | 82 | 1. Copy the files from the `custom_component/oura/` folder into the `custom_component/oura/` of your Home-Assistant installation. 83 | 84 | 1. Configure the sensors following the instructions in `Configuration`. 85 | 1. Restart the Home-Assistant instance. 86 | 87 | ## Configuration 88 | 89 | ### Schema 90 | 91 | Under your `configuration.yaml`, you should include the `sensor` platform under which you should add the following configuration: 92 | 93 | `configuration.yaml` 94 | 95 | ```yaml 96 | sensor: 97 | - platform: oura 98 | access_token: 99 | scan_interval: 100 | sensors: 101 | activity: 102 | name: 103 | attribute_state: 104 | max_backfill: 105 | monitored_dates: 106 | monitored_variables: 107 | heart_rate: 108 | name: 109 | attribute_state: 110 | max_backfill: 111 | monitored_dates: 112 | monitored_variables: 113 | readiness: 114 | name: 115 | attribute_state: 116 | max_backfill: 117 | monitored_dates: 118 | monitored_variables: 119 | sleep: 120 | name: 121 | attribute_state: 122 | max_backfill: 123 | monitored_dates: 124 | monitored_variables: 125 | sleep_periods: 126 | name: 127 | attribute_state: 128 | max_backfill: 129 | monitored_dates: 130 | monitored_variables: 131 | sleep_score: 132 | name: 133 | attribute_state: 134 | max_backfill: 135 | monitored_dates: 136 | monitored_variables: 137 | workouts: 138 | name: 139 | attribute_state: 140 | max_backfill: 141 | monitored_dates: 142 | monitored_variables: 143 | # (...) Potentially other sensors that you may have configured. 144 | 145 | # (...) Other code on your configuration.yaml file. 146 | ``` 147 | 148 | Note: Make sure there are no two sensor platforms. If you already have one `sensor:` configuration, make sure to merge this code with yours. 149 | 150 | ### Parameters 151 | 152 | ### Top level parameters 153 | 154 | - `access_token`: Personal Oura token. See `How to get personal Oura token` section for how to obtain this data. 155 | - `scan_interval`: (Optional) Set how many seconds should pass in between refreshes. As the sleep data should only refresh once per day, we recommend to update every few hours (e.g. 7200 for 2h or 21600 for 6h). 156 | - `sensors`: (Optional) Determines which sensors to import and its configuration. 157 | 158 | ### Sensors parameters 159 | 160 | - `activity`: (Optional) Configures activity sensor. By default, the activity sensor is not configured. Default name: oura_activity. 161 | - `heart_rate`: (Optional) Configures heart rate sensor. By default, the heart rate sensor is not configured. Default name: oura_heart_rate. 162 | - `readiness`: (Optional) Configures readiness sensor. By default, the readiness sensor is not configured. Default name: oura_readiness. 163 | - `sleep`: (Optional) Configures sleep sensor. By default the sleep sensor is added. Default name: oura_sleep. 164 | - `sleep_periods`: (Optional) Configures sleep periods sensor. By default, the sleep periods sensor is not configured. Default name: oura_sleep_periods. 165 | - `workouts`: (Optional) Configures workouts sensor. By default, the workouts sensor is not configured. Default name: oura_workouts. 166 | 167 | ### Individual sensor parameters 168 | 169 | - `name`: (Optional) Name of the sensor (e.g. sleep_score). 170 | - `attribute_state`: (Optional) What monitored variable (i.e. attribute) will be used to define the main state of the sensor. To see the default values, check the `state` section within each sensor description below. 171 | - `max_backfill`: (Optional) How many days before to backfill if a day of data is not available. See `Backfilling strategy` section to understand how this parameter works. Default: 0. 172 | - `monitored_dates`: (Optional) Days that you want to monitor. See `Monitored days` section to understand what day values are supported. Default: yesterday. 173 | - `monitored_variables`: (Optional) Variables that you want to monitor. See `monitored attributes` section within each sensor description below to understand what variables are supported. 174 | 175 | ### Example 176 | 177 | ```yaml 178 | sensor: 179 | - platform: oura 180 | access_token: !secret oura_api_token 181 | scan_interval: 7200 # 2h = 2h * 60min * 60 seconds 182 | sensors: 183 | readiness: {} 184 | sleep: 185 | name: sleep_data 186 | max_backfill: 3 187 | monitored_dates: 188 | - yesterday 189 | - monday 190 | - tuesday 191 | - wednesday 192 | - thursday 193 | - friday 194 | - saturday 195 | - sunday 196 | - 8d_ago # Last week, +1 to compare to yesterday. 197 | # (...) Potentially other sensors that you may have configured. 198 | 199 | # (...) Other code on your configuration.yaml file. 200 | ``` 201 | 202 | This configuration will load two sensors: `readiness` and `sleep`. 203 | 204 | Note: While in most sensors all attributes are optional, the configuration requires a dictionary to be passed. As such, this configuration would fail: 205 | 206 | ```yaml 207 | - platform: oura 208 | access_token: !secret oura_api_token 209 | scan_interval: 7200 # 2h = 2h * 60min * 60 seconds 210 | sensors: 211 | readiness: 212 | sleep: 213 | ``` 214 | 215 | This is because `readiness` and `sleep` both require a dictionary but the system interprets that we are passing a None value. In order to fix this, you can add at least one attribute (e.g. `name`) or you can simply set the value to `{}` which indicates an empty dictionary. The following configuration would be valid: 216 | 217 | ```yaml 218 | - platform: oura 219 | access_token: !secret oura_api_token 220 | scan_interval: 7200 # 2h = 2h * 60min * 60 seconds 221 | sensors: 222 | readiness: {} 223 | sleep: 224 | name: sleep_data 225 | ``` 226 | 227 | In this case, `readiness` sensor would use all the default values, whereas `sleep` sensor would use all the default configuration except for the name that will be using the `sleep_data` value passed. 228 | 229 | ### How to get personal Oura token 230 | 231 | The parameter `access_token` is provided by Oura. Read [this Oura documentation](https://cloud.ouraring.com/docs/authentication#personal-access-tokens) for more information on how to get 232 | them. 233 | 234 | This token is only valid for your personal data. If you need to access data from multiple users, you will need to configure multiple sensors. 235 | 236 | ## Sensors 237 | 238 | ### Common attributes 239 | 240 | #### Monitored days 241 | 242 | This data can be retrieve for multiple days at once. The days supported are: 243 | 244 | - `yesterday`: Previous day. This is the most recent data. 245 | - `Xd_ago`: Number of days ago (e.g. 8d_ago ago to get the data of yesterday last week). 246 | - `monday`, `tuesday`, ..., `sunday`: Previous days of the week. 247 | 248 | #### Backfilling 249 | 250 | ##### What is Backfilling and why it is needed 251 | 252 | Imagine you want to retrieve the previous day of data, but for some reason the data for that day does not exist. This would mean that the data would not be possible to be retrieved and will simply show unknown on the sensor. 253 | 254 | This is frequent for `yesterday` as the data is not yet synced to the systems at midnight (you are still sleeping), but could happen for any day if you forgot to wear the ring. You may want this to stay like this or would prefer to backfill with the most relevant previous data. That process is called backfilling. This component allows to set a backfilling strategy: 255 | 256 | ##### Rule of thumb 257 | 258 | The rule of thumb is that if backfilling is enabled, it will look for the previous day for `yesterday` and `Xd_ago` and for the previous week when using weekdays (e.g. `monday` or `thursday`). 259 | 260 | ##### Full backfilling logic 261 | 262 | If you set the `max_backfill` value to `0`, there will never be backfill of data. If a day of data is not available, it will show unknown. 263 | 264 | If you set the `max_backfill` value to any positive integer, then it will backfill like this: 265 | 266 | - `Xd_ago`: If the data for X days ago is not available, looks for the day before. For example, if the setting is `8d_ago` and is not available, it will look for the data `9d_ago`. The number of previous days will depend on your backfill value. If the backfill is set to any value >1, it will check the value of previous day of data. If the data is found, then it will use this one. If not, it will continue as many times as the value of `max_backfill` (e.g. if the value is 3, it will check the 9d ago, then 10d ago, then 11d ago; it will stop as soon as one of these values is available (e.g. if 10d ago is available, it will not check 11d ago) and will return unknown if none of them has data). 267 | 268 | - `yesterday`: Same as `Xd_ago`. If yesterday is not available, looks for previous day. The number of previous days will depend on your backfill value. If the backfill is set to any value >1, it will check the value of previous day of data (the day before yesterday). If the data is found, then it will use this one. If not, it will continue as many times as the value of `max_backfill` (e.g. if the value is 3, it will check the previous day, then the previous, then the previous; it will stop as soon as one of these values is available and will return unknown if none of them has data). 269 | 270 | - `monday`, `tuesday`, ..., `sunday`: It works similar to `Xd_ago` except in that it looks for the previous week instead of previous day. For example, if last `monday` is not available, it will look for the `monday` of the previous week. If it's available, it will use it. If not, it will continue checking as many weeks back as the backfilling value. 271 | 272 | ### Activity Sensor 273 | 274 | #### Activity Sensor state 275 | 276 | The state of the sensor will show the **score** for the first selected day (recommended: yesterday). 277 | 278 | #### Activity Sensor monitored attributes 279 | 280 | The attributes will contain the daily data for the selected days and monitored variables. 281 | 282 | This sensor supports all the following monitored attributes: 283 | 284 | - `class_5_min` 285 | - `score` 286 | - `active_calories` 287 | - `average_met_minutes` 288 | - `day` 289 | - `meet_daily_targets` 290 | - `move_every_hour` 291 | - `recovery_time` 292 | - `stay_active` 293 | - `training_frequency` 294 | - `training_volume` 295 | - `equivalent_walking_distance` 296 | - `high_activity_met_minutes` 297 | - `high_activity_time` 298 | - `inactivity_alerts` 299 | - `low_activity_met_minutes` 300 | - `low_activity_time` 301 | - `medium_activity_met_minutes` 302 | - `medium_activity_time` 303 | - `met` 304 | - `meters_to_target` 305 | - `non_wear_time` 306 | - `resting_time` 307 | - `sedentary_met_minutes` 308 | - `sedentary_time` 309 | - `steps` 310 | - `target_calories` 311 | - `target_meters` 312 | - `timestamp` 313 | - `total_calories` 314 | 315 | For a definition of all these variables, check [Oura's API](https://cloud.ouraring.com/v2/docs#operation/daily_activity_route_daily_activity_get). 316 | 317 | By default, the following attributes are being monitored: `active_calories`, `high_activity_time`, `low_activity_time`, `medium_activity_time`, `non_wear_time`, `resting_time`, `sedentary_time`, `score`, `target_calories`, `total_calories`, `day`. 318 | 319 | #### Activity Sensor sample output 320 | 321 | **State**: `50` 322 | 323 | **Attributes**: 324 | 325 | ```yaml 326 | yesterday: 327 | score: 50 328 | active_calories: 2 329 | high_activity_time: 0 330 | low_activity_time: 180 331 | medium_activity_time: 0 332 | non_wear_time: 55320 333 | resting_time: 27360 334 | sedentary_time: 3540 335 | target_calories: 550 336 | total_calories: 1702 337 | ``` 338 | 339 | ### Heart Rate Sensor 340 | 341 | #### Heart Rate Sensor state 342 | 343 | The state of the sensor will show the **bpm** for the first selected day (recommended: yesterday). 344 | 345 | #### Heart Rate Sensor monitored attributes 346 | 347 | The attributes will contain the daily data for the selected days and monitored variables. 348 | 349 | This sensor supports all the following monitored attributes: 350 | 351 | - `day` 352 | - `bpm` 353 | - `source` 354 | - `timestamp` 355 | 356 | For a definition of all these variables, check [Oura's API](https://cloud.ouraring.com/v2/docs#operation/heartrate_route_heartrate_get). 357 | 358 | By default, the following attributes are being monitored: `day`, `bpm`, `source`, `timestamp`. 359 | 360 | #### Heart Rate Sensor sample output 361 | 362 | **State**: `58` 363 | 364 | **Attributes**: 365 | 366 | ```yaml 367 | yesterday: 368 | - day: '2023-01-06' 369 | bpm: 58 370 | source: awake 371 | timestamp: '2023-01-06T16:40:38+00:00' 372 | - day: '2023-01-06' 373 | bpm: 53 374 | source: awake 375 | timestamp: '2023-01-06T16:40:52+00:00' 376 | - day: '2023-01-06' 377 | bpm: 55 378 | source: awake 379 | timestamp: '2023-01-06T16:40:53+00:00' 380 | - day: '2023-01-06' 381 | bpm: 60 382 | source: awake 383 | timestamp: '2023-01-06T16:50:59+00:00' 384 | - day: '2023-01-06' 385 | bpm: 64 386 | source: awake 387 | timestamp: '2023-01-06T16:51:25+00:00' 388 | - day: '2023-01-06' 389 | bpm: 62 390 | source: awake 391 | timestamp: '2023-01-06T16:51:31+00:00' 392 | - day: '2023-01-06' 393 | bpm: 64 394 | source: awake 395 | timestamp: '2023-01-06T16:56:02+00:00' 396 | # (...) 397 | ``` 398 | 399 | ### Bedtime Sensor 400 | 401 | #### Bedtime Sensor state 402 | 403 | The state of the sensor will show the **bedtime start hour** for the first selected day. 404 | 405 | #### Bedtime Sensor monitored attributes 406 | 407 | The attributes will contain the daily data for the selected days and monitored variables. 408 | 409 | This sensor supports all the following monitored attributes: 410 | 411 | - `bedtime_window_start`: Recommended bedtime in HH:MM format. 412 | - `bedtime_window_end`: Recommended bedtime in HH:MM format. 413 | - `day` 414 | 415 | For a definition of all these variables, check [Oura's API](https://cloud.ouraring.com/docs/bedtime). 416 | 417 | By default, the following attributes are being monitored: `bedtime_window_start`, `bedtime_window_end`, `day`. 418 | 419 | #### Bedtime Sensor sample output 420 | 421 | **State**: `23:45` 422 | 423 | **Attributes**: 424 | 425 | ```yaml 426 | yesterday: 427 | bedtime_window_start: '23:45' 428 | bedtime_window_end: '00:30' 429 | day: '2023-01-05' 430 | ``` 431 | 432 | ### Readiness Sensor 433 | 434 | #### Readiness Sensor state 435 | 436 | The state of the sensor will show the **score** for the first selected day (recommended: yesterday). 437 | 438 | #### Readiness Sensor monitored attributes 439 | 440 | The attributes will contain the daily data for the selected days and monitored variables. 441 | 442 | This sensor supports all the following monitored attributes: 443 | 444 | - `day`: YYYY-MM-DD of the date of the data point. 445 | - `activity_balance` 446 | - `body_temperature` 447 | - `hrv_balance` 448 | - `previous_day_activity` 449 | - `previous_night` 450 | - `recovery_index` 451 | - `resting_heart_rate` 452 | - `sleep_balance` 453 | - `score` 454 | - `temperature_deviation` 455 | - `temperature_trend_deviation` 456 | - `timestamp` 457 | 458 | For a definition of all these variables, check [Oura's API](https://cloud.ouraring.com/v2/docs#operation/daily_readiness_route_daily_readiness_get). 459 | 460 | By default, the following attributes are being monitored: `activity_balance`, `body_temperature`, `day`, `hrv_balance`, `previous_day_activity`, `previous_night`, `recovery_index`, `resting_heart_rate`, `sleep_balance`, `score`, `temperature_deviation`, `temperature_trend_deviation`, `timestamp`. 461 | 462 | #### Readiness Sensor sample output 463 | 464 | **State**: `94` 465 | 466 | **Attributes**: 467 | 468 | ```yaml 469 | yesterday: 470 | activity_balance: 79 471 | body_temperature: 96 472 | day: '2023-01-03' 473 | hrv_balance: 94 474 | previous_day_activity: null 475 | previous_night: 86 476 | recovery_index: 100 477 | resting_heart_rate: 100 478 | sleep_balance: 98 479 | ``` 480 | 481 | ### Sessions Sensor 482 | 483 | #### Sessions Sensor state 484 | 485 | The state of the sensor will show the **type** for the first selected day (recommended: yesterday) and latest event by timestamp. 486 | 487 | #### Sessions Sensor monitored attributes 488 | 489 | The attributes will contain the daily data for the selected days and monitored variables. 490 | 491 | This sensor supports all the following monitored attributes: 492 | 493 | - `day`: YYYY-MM-DD of the date of the data point. 494 | - `start_datetime` 495 | - `end_datetime` 496 | - `type` 497 | - `heart_rate` 498 | - `heart_rate_variability` 499 | - `mood` 500 | - `motion_count` 501 | 502 | For a definition of all these variables, check [Oura's API](https://cloud.ouraring.com/v2/docs#tag/Sessions). 503 | 504 | By default, the following attributes are being monitored: `day`, `start_datetime`, `end_datetime`, `type`, `heart_rate`, `motion_count`. 505 | 506 | #### Sessions Sensor sample output 507 | 508 | **State**: `94` 509 | 510 | **Attributes**: 511 | 512 | ```yaml 513 | yesterday: 514 | - day: '2021-11-12' 515 | start_datetime: '2021-11-12T12:32:09-08:00' 516 | end_datetime: '2021-11-12T12:40:49-08:00' 517 | type: 'rest' 518 | - day: '2021-11-12' 519 | start_datetime: '2021-11-12T19:45:07-08:00' 520 | end_datetime: '2021-11-12T20:39:27-08:00' 521 | type: 'meditation' 522 | ``` 523 | 524 | ### Sleep Sensor 525 | 526 | #### Sleep Sensor State 527 | 528 | The state of the sensor will show the **sleep efficiency** for the first selected day (recommended: yesterday). 529 | 530 | #### Sleep Sensor monitored attributes 531 | 532 | The attributes will contain the daily data for the selected days and monitored variables. 533 | 534 | This sensor supports all the following monitored attributes: 535 | 536 | - `day`: YYYY-MM-DD of the date of the data point. 537 | - `average_breath`: Average breaths per minute (f.k.a `breath_average`). 538 | - `average_heart_rate`: Average beats per minute of your heart (f.k.a `heart_rate_average`). 539 | - `average_hrv` 540 | - `awake_time`: Time awake in seconds. 541 | - `awake_duration_in_hours`: Time awake in hours. Derived from `awake_time`. 542 | - `bedtime_end`: Timestamp at which you woke up from bed. 543 | - `bedtime_end_hour`: Time (HH:MM) at which you woke up from bed. 544 | - `bedtime_start`: Timestamp at which you went to bed. 545 | - `bedtime_start_hour`: Time (HH:MM) at which you went to bed. 546 | - `deep_sleep_duration`: Number of seconds in deep sleep phase. 547 | - `deep_sleep_duration_in_hours`: Number of hours in deep sleep phase. Derived from `deep_sleep_duration`. 548 | - `efficiency`: Sleep efficiency. Used as the state. 549 | - `heart_rate` 550 | - `hrv` 551 | - `in_bed_duration_in_hours`: Total hours in bed. Derived from `time in bed`. 552 | - `latency` 553 | - `light_sleep_duration`: Number of seconds in light sleep phase. 554 | - `light_sleep_duration_in_hours`: Number of hours in light sleep phase. Derived from `light_sleep_duration`. 555 | - `low_battery_alert` 556 | - `lowest_heart_rate`: Beats per minute of your resting heart (f.k.a `resting_heart_rate`). 557 | - `movement_30_sec` 558 | - `period` 559 | - `readiness_score_delta` 560 | - `rem_sleep_duration`: Number of seconds in REM sleep phase. 561 | - `rem_sleep_duration_in_hours`: Number of hours in REM sleep phase. Derived from `rem_sleep_duration`. 562 | - `restless_periods` 563 | - `sleep_phase_5_min` 564 | - `sleep_score_delta` 565 | - `time_in_bed`: Total number of seconds in bed. 566 | - `total_sleep_duration`: Total seconds of sleep. 567 | - `total_sleep_duration_in_hours`: Total hours of sleep. Derived from `total_sleep_duration`. 568 | - `type`: Type of sleep. 569 | 570 | For a definition of all these variables, check [Oura's API](https://cloud.ouraring.com/v2/docs#operation/sleep_route_sleep_get). 571 | 572 | By default, the following attributes are being monitored: `average_breath`, `average_heart_rate`, `awake_duration_in_hours`, `bedtime_start_hour`, `bedtime_end_hour`, `day`, `deep_sleep_duration_in_hours`, `in_bed_duration_in_hours`, `light_sleep_duration_in_hours`, `lowest_heart_rate`, `rem_sleep_duration_in_hours`, `total_sleep_duration_in_hours`. 573 | 574 | Formerly supported variables that are no longer part of the API (i.e. not supported): 575 | 576 | - `temperature_delta`: Delta temperature from sleeping to day. 577 | 578 | #### Sleep Sensor sample output 579 | 580 | **State**: `48` 581 | 582 | **Attributes**: 583 | 584 | ```yaml 585 | yesterday: 586 | 'day': "2022-07-14" 587 | 'bedtime_start_hour': "02:30" 588 | 'bedtime_end_hour': "09:32" 589 | 'average_breath': 14 590 | 'lowest_heart_rate': 44 591 | 'average_heart_rate': 47 592 | 'deep_sleep_duration_in_hours': 0.72 593 | 'rem_sleep_duration_in_hours': 0.32 594 | 'light_sleep_duration_in_hours': 4.54 595 | 'total_sleep_duration_in_hours': 5.58 596 | 'awake_duration_in_hours': 1.45 597 | 'in_bed_duration_in_hours': 7.0 598 | 599 | 8d_ago: 600 | 'day': "2022-07-07" 601 | 'bedtime_start_hour': "23:29" 602 | 'bedtime_end_hour': "08:05" 603 | 'average_breath': 14 604 | 'lowest_heart_rate': 44 605 | 'average_heart_rate': 48 606 | 'deep_sleep_duration_in_hours': 2.05 607 | 'rem_sleep_duration_in_hours': 0.82 608 | 'light_sleep_duration_in_hours': 4.29 609 | 'total_sleep_duration_in_hours': 7.16 610 | 'awake_duration_in_hours': 1.44 611 | 'in_bed_duration_in_hours': 8.23 612 | ``` 613 | 614 | ### Sleep Periods Sensor 615 | 616 | #### Sleep Periods Sensor State 617 | 618 | Same as [Sleep Sensor](#sleep-sensor-state) but prioritizing the first sleep period for the given day. 619 | 620 | #### Sleep Periods Sensor monitored attributes 621 | 622 | Same as [Sleep Sensor](#sleep-sensor-monitored-attributes). 623 | 624 | By default, the following attributes are being monitored: `average_breath`, `average_heart_rate`, `bedtime_start_hour`, `bedtime_end_hour`, `day`, `total_sleep_duration_in_hours`, `type`. 625 | 626 | #### Sleep Periods Sensor sample output 627 | 628 | **State**: `95` 629 | 630 | **Attributes**: 631 | 632 | ```yaml 633 | yesterday: 634 | - average_breath: 13 635 | average_heart_rate: 56.375 636 | day: '2022-12-30' 637 | bedtime_end_hour: '01:43' 638 | bedtime_start_hour: '01:20' 639 | efficiency: 95 640 | total_sleep_duration_in_hours: 0.09 641 | type: sleep 642 | - average_breath: 14.125 643 | average_heart_rate: 52 644 | day: '2022-12-30' 645 | efficiency: 71 646 | bedtime_end_hour: '08:09' 647 | bedtime_start_hour: '02:33' 648 | total_sleep_duration_in_hours: 5.02 649 | type: long_sleep 650 | 651 | 8d_ago: 652 | - average_breath: 13.25 653 | average_heart_rate: 57.625 654 | day: '2023-12-23' 655 | bedtime_end_hour: '07:45' 656 | bedtime_start_hour: '00:28' 657 | total_sleep_duration_in_hours: 6.67 658 | type: long_sleep 659 | ``` 660 | 661 | ### Sleep Score Sensor 662 | 663 | #### Sleep Score Sensor State 664 | 665 | The state of the sensor will show the **score** for the first selected day (recommended: yesterday). 666 | 667 | #### Sleep Score Sensor monitored attributes 668 | 669 | The attributes will contain the daily data for the selected days and monitored variables. 670 | 671 | This sensor supports all the following monitored attributes: 672 | 673 | - `day`: YYYY-MM-DD of the date of the data point. 674 | - `deep_sleep` 675 | - `efficiency` 676 | - `latency` 677 | - `rem_sleep` 678 | - `restfulness` 679 | - `score` 680 | - `timing` 681 | - `timestamp` 682 | - `total_sleep` 683 | 684 | For a definition of all these variables, check [Oura's API](https://cloud.ouraring.com/v2/docs#tag/Daily-Sleep). 685 | 686 | By default, the following attributes are being monitored: `day`, `score`. 687 | 688 | #### Sleep Score Sensor sample output 689 | 690 | **State**: `77` 691 | 692 | **Attributes**: 693 | 694 | ```yaml 695 | yesterday: 696 | 'day': "2022-07-14" 697 | 'score': 77 698 | 699 | 8d_ago: 700 | 'day': "2022-07-07" 701 | 'score': 91 702 | ``` 703 | 704 | ### Workouts Sensor 705 | 706 | #### Workouts Sensor state 707 | 708 | The state of the sensor will show the **activity** for the first selected day (recommended: yesterday) and latest event by timestamp. 709 | 710 | #### Workouts Sensor monitored attributes 711 | 712 | The attributes will contain the daily data for the selected days and monitored variables. 713 | 714 | This sensor supports all the following monitored attributes: 715 | 716 | - `day`: YYYY-MM-DD of the date of the data point. 717 | - `activity` 718 | - `calories` 719 | - `day` 720 | - `distance` 721 | - `end_datetime` 722 | - `intensity` 723 | - `label` 724 | - `source` 725 | - `start_datetime` 726 | 727 | For a definition of all these variables, check [Oura's API](https://cloud.ouraring.com/v2/docs#operation/workouts_route_workout_get). 728 | 729 | By default, the following attributes are being monitored: `day`, `activity`, `calories`, `intensity`. 730 | 731 | #### Workouts Sensor sample output 732 | 733 | **State**: `cycling` 734 | 735 | **Attributes**: 736 | 737 | ```yaml 738 | yesterday: 739 | - day: '2021-11-12' 740 | activity: 'cycling' 741 | calories: 212 742 | intensity: 'moderate' 743 | - day: '2021-11-12' 744 | activity: 'walking' 745 | calories: 35 746 | intensity: 'low' 747 | ``` 748 | 749 | ### Derived sensors 750 | 751 | While the component retrieves all the data for all the days in one same attribute data, you can re-use this data into template sensors. This is more efficient than creating multiple sensors with multiple API calls. 752 | 753 | Example for breaking up yesterday's data into multiple sensors using the [template integration](https://www.home-assistant.io/integrations/template): 754 | 755 | ```yaml 756 | template: 757 | - sensor: 758 | - name: "Sleep Breath Average Yesterday" 759 | unique_id: sleep_breath_average_yesterday 760 | unit_of_measurement: bpm 761 | state: > 762 | {{ (state_attr('sensor.sleep_data', 'yesterday') or {}).get('average_breath') }} 763 | icon: "mdi:lungs" 764 | 765 | - name: "Sleep Resting Heart Rate Yesterday" 766 | unique_id: sleep_resting_heart_rate_yesterday 767 | unit_of_measurement: "bpm" 768 | state: > 769 | {{ (state_attr('sensor.sleep_data', 'yesterday') or {}).get('lowest_heart_rate') }} 770 | icon: "mdi:heart-pulse" 771 | 772 | - name: "Resting Average Heart Rate Yesterday" 773 | unique_id: resting_heart_rate_average_yesterday 774 | unit_of_measurement: "bpm" 775 | state: > 776 | {{ (state_attr('sensor.sleep_data', 'yesterday') or {}).get('average_heart_rate') }} 777 | icon: "mdi:heart-pulse" 778 | 779 | - name: "Bed Time Yesterday" 780 | unique_id: bed_time_yesterday 781 | state: > 782 | {{ (state_attr('sensor.sleep_data', 'yesterday') or {}).get('bedtime_start_hour') }} 783 | icon: "mdi:sleep" 784 | 785 | - name: "Wake Time Yesterday" 786 | unique_id: wake_time_yesterday 787 | state: > 788 | {{ (state_attr('sensor.sleep_data', 'yesterday') or {}).get('bedtime_end_hour') }} 789 | icon: "mdi:sleep-off" 790 | 791 | - name: "Deep Sleep Yesterday" 792 | unique_id: deep_sleep_yesterday 793 | unit_of_measurement: h 794 | state: > 795 | {{ (state_attr('sensor.sleep_data', 'yesterday') or {}).get('deep_sleep_duration_in_hours') }} 796 | icon: "mdi:bed" 797 | 798 | - name: "Rem Sleep Yesterday" 799 | unique_id: rem_sleep_yesterday 800 | unit_of_measurement: h 801 | state: > 802 | {{ (state_attr('sensor.sleep_data', 'yesterday') or {}).get('rem_sleep_duration_in_hours') }} 803 | icon: "mdi:bed" 804 | 805 | - name: "Light Sleep Yesterday" 806 | unique_id: light_sleep_yesterday 807 | unit_of_measurement: h 808 | state: > 809 | {{ (state_attr('sensor.sleep_data', 'yesterday') or {}).get('light_sleep_duration_in_hours') }} 810 | icon: "mdi:bed" 811 | 812 | - name: "Total Sleep Yesterday" 813 | unique_id: total_sleep_yesterday 814 | unit_of_measurement: h 815 | state: > 816 | {{ (state_attr('sensor.sleep_data', 'yesterday') or {}).get('total_sleep_duration_in_hours') }} 817 | icon: "mdi:sleep" 818 | 819 | - name: "Time Awake Yesterday" 820 | unique_id: time_awake_yesterday 821 | unit_of_measurement: h 822 | state: > 823 | {{ (state_attr('sensor.sleep_data', 'yesterday') or {}).get('awake_duration_in_hours') }} 824 | icon: "mdi:sleep-off" 825 | 826 | - name: "Time In Bed Yesterday" 827 | unique_id: time_in_bed_yesterday 828 | unit_of_measurement: h 829 | state: > 830 | {{ (state_attr('sensor.sleep_data', 'yesterday') or {}).get('in_bed_duration_in_hours') }} 831 | icon: "mdi:bed" 832 | ``` 833 | 834 | Do note that you may need to edit this for your needs and configuration. For example, in this case we are assuming that we want to read the `sleep` sensor data which is called `sleep_data`. From it, we're reading the data from `yesterday` - which is a `monitored_dates`. Inside this, we are reading a few attributes which are either loaded by default or part of the `monitored_variables`. These assumptions may not apply in your case, or you may want to monitor other attributes under other sensors, named differently, or for other dates. 835 | 836 | ### Sensors and Lovelace 837 | 838 | You can leverage the sensor data to create powerful visualizations about your sleep, scores or activities. The objective of this section is to provide a non-extensive list of examples on how this could look like. 839 | 840 | #### Custom Cards 841 | 842 | ##### Using apexcharts-card 843 | 844 | [apexcharts-card](https://github.com/RomRider/apexcharts-card) is a custom card which provides a customizable graph card for Home-Assistant's Lovelace UI. 845 | 846 | ###### Score card using apexcharts-card 847 | 848 | ![Oura chart with Oura score](docs/img/apex-charts-scores.png) 849 | 850 |
851 | configuration.yaml 852 | 853 | ```yaml 854 | sensor: 855 | - platform: oura 856 | access_token: 857 | sensors: 858 | sleep_score: 859 | name: oura_sleep_score 860 | max_backfill: 0 861 | monitored_dates: 862 | - 0d_ago 863 | - 1d_ago 864 | - 2d_ago 865 | - 3d_ago 866 | - 4d_ago 867 | - 5d_ago 868 | - 6d_ago 869 | - 7d_ago 870 | activity: 871 | name: oura_activity 872 | max_backfill: 0 873 | monitored_dates: 874 | - 0d_ago 875 | - 1d_ago 876 | - 2d_ago 877 | - 3d_ago 878 | - 4d_ago 879 | - 5d_ago 880 | - 6d_ago 881 | - 7d_ago 882 | readiness: 883 | name: oura_readiness 884 | max_backfill: 0 885 | monitored_dates: 886 | - 0d_ago 887 | - 1d_ago 888 | - 2d_ago 889 | - 3d_ago 890 | - 4d_ago 891 | - 5d_ago 892 | - 6d_ago 893 | - 7d_ago 894 | ``` 895 |
896 | 897 |
898 | Lovelace Card (YAML) 899 | 900 | ```yaml 901 | - type: custom:apexcharts-card 902 | apex_config: 903 | chart: 904 | height: 150px 905 | xaxis: 906 | type: datetime 907 | labels: 908 | format: ddd 909 | graph_span: 7d 910 | header: 911 | show: true 912 | show_states: true 913 | colorize_states: true 914 | title: Oura Scores 915 | standard_format: true 916 | series: 917 | - entity: sensor.oura_sleep_score 918 | name: Sleep 919 | color: '#20bf6b' 920 | show: 921 | in_chart: false 922 | in_header: true 923 | - entity: sensor.oura_sleep_score 924 | name: Sleep 925 | color: '#20bf6b' 926 | data_generator: | 927 | var data = []; 928 | var attributes = entity.attributes; 929 | for(let day in attributes) { 930 | if (typeof attributes[day] == 'object' && attributes[day] !== null) { 931 | var datapointday = moment(attributes[day].day); 932 | var datapointscore = attributes[day].score; 933 | if(datapointscore == null){ 934 | datapointscore = 0 935 | } 936 | data.push({x: datapointday, y: datapointscore}); 937 | } 938 | } 939 | return data; 940 | type: line 941 | show: 942 | in_chart: true 943 | in_header: false 944 | - entity: sensor.oura_readiness 945 | name: Readiness 946 | color: '#45aaf2' 947 | show: 948 | in_chart: false 949 | in_header: true 950 | - entity: sensor.oura_readiness 951 | name: Readiness 952 | color: '#45aaf2' 953 | data_generator: | 954 | var data = []; 955 | var attributes = entity.attributes; 956 | for(let day in attributes) { 957 | if (typeof attributes[day] == 'object' && attributes[day] !== null) { 958 | var datapointday = moment(attributes[day].day); 959 | var datapointscore = attributes[day].score; 960 | if(datapointscore == null){ 961 | datapointscore = 0 962 | } 963 | data.push({x: datapointday, y: datapointscore}); 964 | } 965 | } 966 | return data; 967 | type: line 968 | show: 969 | in_chart: true 970 | in_header: false 971 | - entity: sensor.oura_activity 972 | name: Activity 973 | color: '#fed330' 974 | show: 975 | in_chart: false 976 | in_header: true 977 | - entity: sensor.oura_activity 978 | name: Activity 979 | color: '#fed330' 980 | data_generator: | 981 | var data = []; 982 | var attributes = entity.attributes; 983 | for(let day in attributes) { 984 | if (typeof attributes[day] == 'object' && attributes[day] !== null) { 985 | var datapointday = moment(attributes[day].day); 986 | var datapointscore = attributes[day].score; 987 | if(datapointscore == null){ 988 | datapointscore = 0 989 | } 990 | data.push({x: datapointday, y: datapointscore}); 991 | } 992 | } 993 | return data; 994 | type: line 995 | show: 996 | in_chart: true 997 | in_header: false 998 | ``` 999 |
1000 | 1001 | ###### Sleep trend card using apexcharts-card 1002 | 1003 | ![image](docs/img/apex-charts-sleep-trend.png) 1004 | 1005 |
1006 | configuration.yaml 1007 | 1008 | ```yaml 1009 | sensor: 1010 | - platform: oura 1011 | access_token: 1012 | sensors: 1013 | sleep_score: 1014 | name: oura_sleep_score 1015 | max_backfill: 0 1016 | monitored_dates: 1017 | - 0d_ago 1018 | - 1d_ago 1019 | - 2d_ago 1020 | - 3d_ago 1021 | - 4d_ago 1022 | - 5d_ago 1023 | - 6d_ago 1024 | - 7d_ago 1025 | name: oura_sleep_metrics 1026 | max_backfill: 0 1027 | monitored_dates: 1028 | - 0d_ago 1029 | - 1d_ago 1030 | - 2d_ago 1031 | - 3d_ago 1032 | - 4d_ago 1033 | - 5d_ago 1034 | - 6d_ago 1035 | - 7d_ago 1036 | - 8d_ago 1037 | ``` 1038 |
1039 | 1040 |
1041 | Lovelace Card (YAML) 1042 | 1043 | ```yaml 1044 | - type: custom:apexcharts-card 1045 | apex_config: 1046 | chart: 1047 | height: 200px 1048 | graph_span: 7d 1049 | header: 1050 | show: true 1051 | show_states: true 1052 | colorize_states: true 1053 | title: Sleep 1054 | standard_format: true 1055 | series: 1056 | - entity: sensor.oura_sleep_score 1057 | name: Score 1058 | show: 1059 | in_chart: false 1060 | color: white 1061 | - entity: sensor.0d_sleep_average_hr 1062 | name: Avg HR 1063 | show: 1064 | in_chart: false 1065 | - entity: sensor.0d_lowest_heart_rate 1066 | name: Lowest HR 1067 | show: 1068 | in_chart: false 1069 | - entity: sensor.oura_sleep_metrics 1070 | name: In Bed 1071 | color: grey 1072 | data_generator: | 1073 | var data = []; 1074 | var attributes = entity.attributes; 1075 | for(let day in attributes) { 1076 | if (typeof attributes[day] == 'object' && attributes[day] !== null) { 1077 | var datapointday = moment(attributes[day].day); 1078 | var datapointscore = attributes[day].in_bed_duration_in_hours; 1079 | if(datapointscore == null){ 1080 | datapointscore = 0; 1081 | } 1082 | data.push({x: datapointday, y: datapointscore}); 1083 | } 1084 | } 1085 | return data; 1086 | type: area 1087 | show: 1088 | in_chart: true 1089 | in_header: false 1090 | - entity: sensor.oura_sleep_metrics 1091 | name: Total Sleep 1092 | color: purple 1093 | data_generator: | 1094 | var data = []; 1095 | var attributes = entity.attributes; 1096 | for(let day in attributes) { 1097 | if (typeof attributes[day] == 'object' && attributes[day] !== null) { 1098 | var datapointday = moment(attributes[day].day); 1099 | var datapointscore = attributes[day].total_sleep_duration_in_hours; 1100 | if(datapointscore == null){ 1101 | datapointscore = 0; 1102 | } 1103 | data.push({x: datapointday, y: datapointscore}); 1104 | } 1105 | } 1106 | return data; 1107 | type: area 1108 | show: 1109 | in_chart: true 1110 | in_header: false 1111 | - entity: sensor.oura_sleep_metrics 1112 | name: REM 1113 | color: '#20bf6b' 1114 | data_generator: | 1115 | var data = []; 1116 | var attributes = entity.attributes; 1117 | for(let day in attributes) { 1118 | if (typeof attributes[day] == 'object' && attributes[day] !== null) { 1119 | var datapointday = moment(attributes[day].day); 1120 | var datapointscore = attributes[day].rem_sleep_duration_in_hours; 1121 | if(datapointscore == null){ 1122 | datapointscore = 0; 1123 | } 1124 | data.push({x: datapointday, y: datapointscore}); 1125 | } 1126 | } 1127 | return data; 1128 | type: column 1129 | show: 1130 | in_chart: true 1131 | in_header: false 1132 | - entity: sensor.oura_sleep_metrics 1133 | name: Deep 1134 | color: '#45aaf2' 1135 | data_generator: | 1136 | var data = []; 1137 | var attributes = entity.attributes; 1138 | for(let day in attributes) { 1139 | if (typeof attributes[day] == 'object' && attributes[day] !== null) { 1140 | var datapointday = moment(attributes[day].day); 1141 | var datapointscore = attributes[day].deep_sleep_duration_in_hours; 1142 | if(datapointscore == null){ 1143 | datapointscore = 0; 1144 | } 1145 | data.push({x: datapointday, y: datapointscore}); 1146 | } 1147 | } 1148 | return data; 1149 | type: column 1150 | show: 1151 | in_chart: true 1152 | in_header: false 1153 | - entity: sensor.oura_sleep_metrics 1154 | name: Light 1155 | color: '#fed330' 1156 | data_generator: | 1157 | var data = []; 1158 | var attributes = entity.attributes; 1159 | for(let day in attributes) { 1160 | if (typeof attributes[day] == 'object' && attributes[day] !== null) { 1161 | var datapointday = moment(attributes[day].day); 1162 | var datapointscore = attributes[day].light_sleep_duration_in_hours; 1163 | if(datapointscore == null){ 1164 | datapointscore = 0; 1165 | } 1166 | data.push({x: datapointday, y: datapointscore}); 1167 | } 1168 | } 1169 | return data; 1170 | type: column 1171 | show: 1172 | in_chart: true 1173 | in_header: false 1174 | - entity: sensor.oura_sleep_metrics 1175 | name: Awake 1176 | color: '#fc5c65' 1177 | data_generator: | 1178 | var data = []; 1179 | var attributes = entity.attributes; 1180 | for(let day in attributes) { 1181 | if (typeof attributes[day] == 'object' && attributes[day] !== null) { 1182 | var datapointday = moment(attributes[day].day); 1183 | var datapointscore = attributes[day].awake_duration_in_hours; 1184 | if(datapointscore == null){ 1185 | datapointscore = 0; 1186 | } 1187 | data.push({x: datapointday, y: datapointscore}); 1188 | } 1189 | } 1190 | return data; 1191 | type: column 1192 | show: 1193 | in_chart: true 1194 | in_header: false 1195 | ``` 1196 |
1197 | 1198 | ## Frequently Asked Questions (FAQs) and Common Issues 1199 | 1200 | **I am getting `NoURLAvailableError` during set up.** 1201 | 1202 | In order for this Oura component to complete the sign up process, at least one URL must be configured on your Home-Assistant instance. Follow [this process](https://www.home-assistant.io/docs/configuration/basic/) to set one up on `Settings > System > Network` or on your `configuration.yaml` file. 1203 | -------------------------------------------------------------------------------- /custom_components/oura/__init__.py: -------------------------------------------------------------------------------- 1 | """Oura sleep integration.""" 2 | -------------------------------------------------------------------------------- /custom_components/oura/api.py: -------------------------------------------------------------------------------- 1 | """Provides an OuraApi class to handle interactions with Oura API.""" 2 | 3 | import enum 4 | import requests 5 | from .helpers import hass_helper 6 | 7 | # Oura API config. 8 | _OURA_API_V1 = 'https://api.ouraring.com/v1' 9 | _OURA_API_V2 = 'https://api.ouraring.com/v2' 10 | 11 | 12 | class OuraEndpoints(enum.Enum): 13 | """Represents Oura endpoints.""" 14 | ACTIVITY = '{}/usercollection/daily_activity'.format(_OURA_API_V2) 15 | BEDTIME = '{}/bedtime'.format(_OURA_API_V1) 16 | HEART_RATE = '{}/usercollection/heartrate'.format(_OURA_API_V2) 17 | READINESS = '{}/usercollection/daily_readiness'.format(_OURA_API_V2) 18 | SESSIONS = '{}/usercollection/session'.format(_OURA_API_V2) 19 | SLEEP_PERIODS = '{}/usercollection/sleep'.format(_OURA_API_V2) 20 | SLEEP_SCORE = '{}/usercollection/daily_sleep'.format(_OURA_API_V2) 21 | WORKOUTS = '{}/usercollection/workout'.format(_OURA_API_V2) 22 | 23 | 24 | class OuraApi(object): 25 | """Handles Oura API interactions. 26 | 27 | Properties: 28 | token_file_name: Name of the file that contains the sensor credentials. 29 | 30 | Methods: 31 | get_oura_data: fetches data from Oura API for given endpoint. 32 | """ 33 | 34 | def __init__(self, sensor, access_token): 35 | """Instantiates a new OuraApi class. 36 | 37 | Args: 38 | sensor: Oura sensor to which this api is linked. 39 | access_token: Personal access token. 40 | """ 41 | self._sensor = sensor 42 | self._access_token = access_token 43 | self._hass_url = hass_helper.get_url(self._sensor._hass) 44 | 45 | def _get_oura_data_legacy(self, endpoint, start_date, end_date=None): 46 | """Fetches data for a OuraEndpoint and date for API v1. 47 | 48 | Args: 49 | start_date: Day for which to fetch data(YYYY-MM-DD). 50 | end_date: Last day for which to retrieve data(YYYY-MM-DD). 51 | If same as start_date, leave empty. 52 | 53 | Returns: 54 | Dictionary containing Oura sleep data. 55 | """ 56 | api_url = endpoint.value 57 | 58 | params = { 59 | 'access_token': self._access_token, 60 | } 61 | if start_date: 62 | params['start'] = start_date 63 | if end_date: 64 | params['end'] = end_date 65 | 66 | response = requests.get(api_url, params=params) 67 | response_data = response.json() 68 | 69 | return response_data 70 | 71 | def get_oura_data(self, endpoint, start_date, end_date=None): 72 | """Fetches data for a OuraEndpoint and date. 73 | 74 | TODO: detect whether data was retrieved. 75 | TODO: detect whether next_token is present. If yes, fetch and combine. 76 | 77 | Args: 78 | start_date: Day for which to fetch data(YYYY-MM-DD). 79 | end_date: Last day for which to retrieve data(YYYY-MM-DD). 80 | If same as start_date, leave empty. 81 | 82 | Returns: 83 | Dictionary containing Oura sleep data. 84 | """ 85 | api_url = endpoint.value 86 | 87 | if _OURA_API_V1 in api_url: 88 | return self._get_oura_data_legacy(endpoint, start_date, end_date) 89 | 90 | params = {} 91 | if start_date: 92 | params['start_date'] = start_date 93 | if end_date: 94 | params['end_date'] = end_date 95 | 96 | headers = { 97 | 'Authorization': 'Bearer {}'.format(self._access_token) 98 | } 99 | 100 | response = requests.get(api_url, params=params, headers=headers) 101 | response_data = response.json() 102 | 103 | return response_data 104 | -------------------------------------------------------------------------------- /custom_components/oura/const.py: -------------------------------------------------------------------------------- 1 | """Provides some constant for home assistant common things.""" 2 | 3 | CONF_ATTRIBUTE_STATE = 'attribute_state' 4 | 5 | CONF_BACKFILL = 'max_backfill' 6 | DEFAULT_BACKFILL = 0 7 | 8 | CONF_MONITORED_DATES = 'monitored_dates' 9 | DEFAULT_MONITORED_DATES = ['yesterday'] 10 | -------------------------------------------------------------------------------- /custom_components/oura/helpers/date_helper.py: -------------------------------------------------------------------------------- 1 | """Provides some basic date functionality to work with Oura dates.""" 2 | 3 | import datetime 4 | 5 | 6 | def seconds_to_hours(time_in_seconds): 7 | """Parses times in seconds and converts it to hours.""" 8 | return round(int(time_in_seconds) / (60 * 60), 2) 9 | 10 | 11 | def add_days_to_string_date(string_date, days_to_add): 12 | """Adds (or subtracts) days from a string date. 13 | 14 | Args: 15 | string_date: Original date in YYYY-MM-DD. 16 | days_to_add: Number of days to add. Negative to subtract. 17 | 18 | Returns: 19 | Date in YYYY-MM-DD with days added. 20 | """ 21 | date = datetime.datetime.strptime(string_date, '%Y-%m-%d') 22 | new_date = date + datetime.timedelta(days=days_to_add) 23 | return str(new_date.date()) 24 | 25 | 26 | def add_time_to_string_time(string_time, seconds_to_add): 27 | """Adds (or subtracts) seconds from an hour date. 28 | 29 | Args: 30 | string_time: Original time in HH:MM. 31 | seconds_to_add: Number of seconds to add. Negative to subtract. 32 | 33 | Returns: 34 | Time in HH:MM with seconds added. 35 | """ 36 | time = datetime.datetime.strptime(string_time, '%H:%M') 37 | new_time = time + datetime.timedelta(seconds=seconds_to_add) 38 | return str(new_time.strftime('%H:%M')) 39 | -------------------------------------------------------------------------------- /custom_components/oura/helpers/hass_helper.py: -------------------------------------------------------------------------------- 1 | """Abstracts hass logic away from Oura logic.""" 2 | 3 | import logging 4 | 5 | # Safe importing as this module was not existing on previous versions 6 | # of Home-Assistant. 7 | try: 8 | from homeassistant.helpers import network 9 | except ModuleNotFoundError: 10 | logging.debug('Network module not found.') 11 | 12 | 13 | def get_url(hass): 14 | """Gets the required Home-Assistant URL for validation. 15 | 16 | Args: 17 | hass: Hass instance. 18 | 19 | Returns: 20 | Home-Assistant URL. 21 | """ 22 | if network: 23 | try: 24 | return network.get_url( 25 | hass, 26 | allow_external=True, 27 | allow_internal=True, 28 | allow_ip=True, 29 | prefer_external=True, 30 | require_ssl=False) 31 | except AttributeError: 32 | logging.debug( 33 | 'Hass version does not have get_url helper, using fall back.') 34 | 35 | base_url = hass.config.api.base_url 36 | if base_url: 37 | return base_url 38 | 39 | raise ValueError('Unable to obtain HASS url.') 40 | -------------------------------------------------------------------------------- /custom_components/oura/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "domain": "oura", 3 | "name": "Oura Ring", 4 | "version": "3.0", 5 | "documentation": "https://github.com/nitobuendia/oura-custom-component", 6 | "issue_tracker": "https://github.com/nitobuendia/oura-custom-component/issues", 7 | "dependencies": [], 8 | "codeowners": [ 9 | "@nitobuendia" 10 | ], 11 | "requirements": [] 12 | } 13 | -------------------------------------------------------------------------------- /custom_components/oura/sensor.py: -------------------------------------------------------------------------------- 1 | """Sensor from Oura Ring data.""" 2 | 3 | from homeassistant import const 4 | from homeassistant.helpers import config_validation as cv 5 | import voluptuous as vol 6 | from . import sensor_activity 7 | from . import sensor_bedtime 8 | from . import sensor_heart_rate 9 | from . import sensor_readiness 10 | from . import sensor_sessions 11 | from . import sensor_sleep 12 | from . import sensor_sleep_periods 13 | from . import sensor_sleep_score 14 | from . import sensor_workouts 15 | 16 | 17 | _SENSORS_SCHEMA = { 18 | vol.Optional(sensor_activity.CONF_KEY_NAME): sensor_activity.CONF_SCHEMA, 19 | vol.Optional(sensor_bedtime.CONF_KEY_NAME): sensor_bedtime.CONF_SCHEMA, 20 | vol.Optional( 21 | sensor_heart_rate.CONF_KEY_NAME): sensor_heart_rate.CONF_SCHEMA, 22 | vol.Optional(sensor_readiness.CONF_KEY_NAME): sensor_readiness.CONF_SCHEMA, 23 | vol.Optional(sensor_sessions.CONF_KEY_NAME): sensor_sessions.CONF_SCHEMA, 24 | vol.Optional(sensor_sleep.CONF_KEY_NAME): sensor_sleep.CONF_SCHEMA, 25 | vol.Optional( 26 | sensor_sleep_periods.CONF_KEY_NAME): sensor_sleep_periods.CONF_SCHEMA, 27 | vol.Optional( 28 | sensor_sleep_score.CONF_KEY_NAME): sensor_sleep_score.CONF_SCHEMA, 29 | vol.Optional(sensor_workouts.CONF_KEY_NAME): sensor_workouts.CONF_SCHEMA, 30 | } 31 | 32 | PLATFORM_SCHEMA = cv.PLATFORM_SCHEMA.extend({ 33 | vol.Required(const.CONF_ACCESS_TOKEN): cv.string, 34 | vol.Optional(const.CONF_SENSORS): _SENSORS_SCHEMA, 35 | }) 36 | 37 | 38 | async def setup(hass, config): 39 | """No set up required. Token retrieval logic handled by sensor.""" 40 | return True 41 | 42 | 43 | async def async_setup_platform( 44 | hass, config, async_add_entities, discovery_info=None): 45 | """Adds sensor platform to the list of platforms.""" 46 | sensors_config = config.get(const.CONF_SENSORS, {}) 47 | sensors = [] 48 | 49 | if sensor_activity.CONF_KEY_NAME in sensors_config: 50 | sensors.append(sensor_activity.OuraActivitySensor(config, hass)) 51 | 52 | if sensor_bedtime.CONF_KEY_NAME in sensors_config: 53 | sensors.append(sensor_bedtime.OuraBedtimeSensor(config, hass)) 54 | 55 | if sensor_heart_rate.CONF_KEY_NAME in sensors_config: 56 | sensors.append(sensor_heart_rate.OuraHeartRateSensor(config, hass)) 57 | 58 | if sensor_readiness.CONF_KEY_NAME in sensors_config: 59 | sensors.append(sensor_readiness.OuraReadinessSensor(config, hass)) 60 | 61 | if sensor_sessions.CONF_KEY_NAME in sensors_config: 62 | sensors.append(sensor_sessions.OuraSessionsSensor(config, hass)) 63 | 64 | if sensor_sleep.CONF_KEY_NAME in sensors_config: 65 | sensors.append(sensor_sleep.OuraSleepSensor(config, hass)) 66 | 67 | if sensor_sleep_periods.CONF_KEY_NAME in sensors_config: 68 | sensors.append(sensor_sleep_periods.OuraSleepPeriodsSensor(config, hass)) 69 | 70 | if sensor_sleep_score.CONF_KEY_NAME in sensors_config: 71 | sensors.append(sensor_sleep_score.OuraSleepScoreSensor(config, hass)) 72 | 73 | if sensor_workouts.CONF_KEY_NAME in sensors_config: 74 | sensors.append(sensor_workouts.OuraWorkoutsSensor(config, hass)) 75 | 76 | async_add_entities(sensors, True) 77 | -------------------------------------------------------------------------------- /custom_components/oura/sensor_activity.py: -------------------------------------------------------------------------------- 1 | """Provides an activity sensor.""" 2 | 3 | import voluptuous as vol 4 | from homeassistant import const 5 | from homeassistant.helpers import config_validation as cv 6 | from . import api 7 | from . import const as oura_const 8 | from . import sensor_base_dated 9 | 10 | # Sensor configuration 11 | _DEFAULT_NAME = 'oura_activity' 12 | 13 | CONF_KEY_NAME = 'activity' 14 | _DEFAULT_MONITORED_VARIABLES = [ 15 | 'active_calories', 16 | 'day', 17 | 'high_activity_time', 18 | 'low_activity_time', 19 | 'medium_activity_time', 20 | 'non_wear_time', 21 | 'resting_time', 22 | 'sedentary_time', 23 | 'score', 24 | 'target_calories', 25 | 'total_calories', 26 | ] 27 | _SUPPORTED_MONITORED_VARIABLES = [ 28 | 'class_5_min', 29 | 'score', 30 | 'active_calories', 31 | 'average_met_minutes', 32 | 'day', 33 | 'meet_daily_targets', 34 | 'move_every_hour', 35 | 'recovery_time', 36 | 'stay_active', 37 | 'training_frequency', 38 | 'training_volume', 39 | 'equivalent_walking_distance', 40 | 'high_activity_met_minutes', 41 | 'high_activity_time', 42 | 'inactivity_alerts', 43 | 'low_activity_met_minutes', 44 | 'low_activity_time', 45 | 'medium_activity_met_minutes', 46 | 'medium_activity_time', 47 | 'met', 48 | 'meters_to_target', 49 | 'non_wear_time', 50 | 'resting_time', 51 | 'sedentary_met_minutes', 52 | 'sedentary_time', 53 | 'steps', 54 | 'target_calories', 55 | 'target_meters', 56 | 'timestamp', 57 | 'total_calories', 58 | ] 59 | 60 | CONF_SCHEMA = { 61 | vol.Optional(const.CONF_NAME, default=_DEFAULT_NAME): cv.string, 62 | 63 | vol.Optional( 64 | oura_const.CONF_MONITORED_DATES, 65 | default=oura_const.DEFAULT_MONITORED_DATES 66 | ): cv.ensure_list, 67 | 68 | vol.Optional( 69 | const.CONF_MONITORED_VARIABLES, 70 | default=_DEFAULT_MONITORED_VARIABLES 71 | ): vol.All(cv.ensure_list, [vol.In(_SUPPORTED_MONITORED_VARIABLES)]), 72 | 73 | vol.Optional( 74 | oura_const.CONF_BACKFILL, 75 | default=oura_const.DEFAULT_BACKFILL 76 | ): cv.positive_int, 77 | } 78 | 79 | _EMPTY_SENSOR_ATTRIBUTE = { 80 | variable: None for variable in _SUPPORTED_MONITORED_VARIABLES 81 | } 82 | 83 | 84 | class OuraActivitySensor(sensor_base_dated.OuraDatedSensor): 85 | """Representation of an Oura Ring Activity sensor. 86 | 87 | Attributes: 88 | name: name of the sensor. 89 | state: state of the sensor. 90 | extra_state_attributes: attributes of the sensor. 91 | 92 | Methods: 93 | async_update: updates sensor data. 94 | """ 95 | 96 | def __init__(self, config, hass): 97 | """Initializes the sensor.""" 98 | activity_config = ( 99 | config.get(const.CONF_SENSORS, {}).get(CONF_KEY_NAME, {})) 100 | super(OuraActivitySensor, self).__init__(config, hass, activity_config) 101 | 102 | self._api_endpoint = api.OuraEndpoints.ACTIVITY 103 | self._empty_sensor = _EMPTY_SENSOR_ATTRIBUTE 104 | self._main_state_attribute = 'score' 105 | 106 | def parse_individual_data_point(self, data_point): 107 | """Parses the individual day or data point. 108 | 109 | Args: 110 | data_point: Object for an individual day or data point. 111 | 112 | Returns: 113 | Modified data point with right parsed data. 114 | """ 115 | data_point_copy = {} 116 | data_point_copy.update(data_point) 117 | 118 | contributors = data_point_copy.get('contributors', {}) 119 | data_point_copy.update(contributors) 120 | del data_point_copy['contributors'] 121 | 122 | return data_point_copy 123 | -------------------------------------------------------------------------------- /custom_components/oura/sensor_base.py: -------------------------------------------------------------------------------- 1 | """Provides a base OuraSensor class to handle interactions with Oura API.""" 2 | 3 | import logging 4 | from homeassistant import const 5 | from homeassistant.helpers import config_validation as cv 6 | from homeassistant.helpers import entity 7 | from . import api 8 | 9 | SENSOR_NAME = 'oura' 10 | 11 | 12 | class OuraSensor(entity.Entity): 13 | """Representation of an Oura Ring sensor. 14 | 15 | Attributes: 16 | name: name of the sensor. 17 | state: state of the sensor. 18 | extra_state_attributes: attributes of the sensor. 19 | 20 | Methods: 21 | async_update: updates sensor data. 22 | """ 23 | 24 | def __init__(self, config, hass): 25 | """Initializes the sensor.""" 26 | 27 | # Basic sensor config. 28 | self._config = config 29 | self._sensor_config = {} 30 | self._hass = hass 31 | self._name = SENSOR_NAME 32 | 33 | # API config. 34 | access_token = config.get(const.CONF_ACCESS_TOKEN) 35 | self._api = api.OuraApi(self, access_token) 36 | 37 | # Attributes. 38 | self._state = None # Sleep score. 39 | self._attributes = {} 40 | 41 | # Sensor properties. 42 | @property 43 | def name(self): 44 | """Returns the name of the sensor.""" 45 | return self._name 46 | 47 | @property 48 | def state(self): 49 | """Returns the state of the sensor.""" 50 | return self._state 51 | 52 | @property 53 | def extra_state_attributes(self): 54 | """Returns the sensor attributes.""" 55 | return self._attributes 56 | 57 | # Sensor methods. 58 | def _update(self): 59 | """To be implemented by the sensor.""" 60 | 61 | async def async_update(self): 62 | """Updates the state and attributes of the sensor.""" 63 | await self._hass.async_add_executor_job(self._update) 64 | -------------------------------------------------------------------------------- /custom_components/oura/sensor_base_dated.py: -------------------------------------------------------------------------------- 1 | """Provides a base OuraSensor class for dated Oura endpoints.""" 2 | 3 | import datetime 4 | import enum 5 | import logging 6 | import re 7 | from homeassistant import const 8 | from . import const as oura_const 9 | from . import sensor_base 10 | from .helpers import date_helper 11 | 12 | 13 | class MonitoredDayType(enum.Enum): 14 | """Types of days which can be monitored.""" 15 | UNKNOWN = 0 16 | YESTERDAY = 1 17 | WEEKDAY = 2 18 | DAYS_AGO = 3 19 | 20 | 21 | _FULL_WEEKDAY_NAMES = [ 22 | 'monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday', 23 | 'sunday', 24 | ] 25 | 26 | 27 | class OuraDatedSensor(sensor_base.OuraSensor): 28 | """Representation of an Oura Ring sensor with daily data. 29 | 30 | Attributes: 31 | name: name of the sensor. 32 | state: state of the sensor. 33 | extra_state_attributes: attributes of the sensor. 34 | 35 | Methods: 36 | filter_individual_data_point: Filters a data point from the API. 37 | get_sensor_data_from_api: Fetches data from the API. 38 | parse_individual_data_point: Parses a data point from the API. 39 | parse_sensor_data: Parses data from the API. 40 | """ 41 | 42 | def __init__(self, config, hass, sensor_config=None): 43 | """Initializes the sensor. 44 | 45 | Args: 46 | config: Platform configuration. 47 | hass: Home-Assistant object. 48 | sensor_config: Sub-section of config holding the particular sensor info. 49 | """ 50 | super(OuraDatedSensor, self).__init__(config, hass) 51 | 52 | # Dated sensor config. 53 | if not sensor_config: 54 | sensor_config = config 55 | self._sensor_config.update(sensor_config) 56 | 57 | self._name = self._sensor_config.get(const.CONF_NAME) 58 | self._backfill = self._sensor_config.get(oura_const.CONF_BACKFILL) 59 | self._main_state_attribute = self._sensor_config.get( 60 | oura_const.CONF_ATTRIBUTE_STATE) 61 | 62 | self._monitored_variables = [ 63 | variable_name.lower() 64 | for variable_name 65 | in self._sensor_config.get(const.CONF_MONITORED_VARIABLES) 66 | ] if self._sensor_config.get(const.CONF_MONITORED_VARIABLES) else [] 67 | 68 | self._monitored_dates = [ 69 | date_name.lower() 70 | for date_name 71 | in self._sensor_config.get(oura_const.CONF_MONITORED_DATES) 72 | ] if self._sensor_config.get(oura_const.CONF_MONITORED_DATES) else [] 73 | 74 | # API endpoint for this sensor. 75 | self._api_endpoint = '' 76 | # Empty daily sensor data. 77 | self._empty_sensor = {} 78 | 79 | def _filter_monitored_variables(self, sensor_data): 80 | """Filters the sensor data to only contain monitored variables. 81 | 82 | Args: 83 | sensor_data: Map of dates to sensor data. 84 | 85 | Returns: 86 | Same sensor_data map but filtered to only contain monitored variables. 87 | """ 88 | data = {} 89 | data.update(sensor_data) 90 | 91 | if not data: 92 | return data 93 | 94 | for date_attributes in list(data.values()): 95 | for variable in list(date_attributes.keys()): 96 | if variable not in self._monitored_variables: 97 | del date_attributes[variable] 98 | return data 99 | 100 | def _get_backfill_date(self, date_name, date_value): 101 | """Gets the backfill date for a given date and date name. 102 | 103 | Args: 104 | date_name: Date name to backfill. 105 | date_value: Last checked value. 106 | 107 | Returns: 108 | Potential backfill date. None if Unknown. 109 | """ 110 | date_type = self._get_date_type_by_name(date_name) 111 | 112 | if date_type == MonitoredDayType.YESTERDAY: 113 | return date_helper.add_days_to_string_date(date_value, -1) 114 | elif date_type == MonitoredDayType.WEEKDAY: 115 | return date_helper.add_days_to_string_date(date_value, -7) 116 | elif date_type == MonitoredDayType.DAYS_AGO: 117 | return date_helper.add_days_to_string_date(date_value, -1) 118 | else: 119 | return None 120 | 121 | def _get_date_by_name(self, date_name): 122 | """Translates a date name into YYYY-MM-DD format for the given day. 123 | 124 | Args: 125 | date_name: Name of the date to get. Supported: 126 | yesterday, weekday(e.g. monday, tuesday), Xdays_ago(e.g. 3days_ago). 127 | 128 | Returns: 129 | Date in YYYY-MM-DD format. 130 | """ 131 | date_type = self._get_date_type_by_name(date_name) 132 | today = datetime.date.today() 133 | days_ago = None 134 | if date_type == MonitoredDayType.YESTERDAY: 135 | days_ago = 1 136 | 137 | elif date_type == MonitoredDayType.WEEKDAY: 138 | date_index = _FULL_WEEKDAY_NAMES.index(date_name) 139 | days_ago = ( 140 | today.weekday() - date_index 141 | if today.weekday() > date_index else 142 | 7 + today.weekday() - date_index 143 | ) 144 | 145 | elif date_type == MonitoredDayType.DAYS_AGO: 146 | digits_regex = re.compile(r'\d+') 147 | digits_match = digits_regex.match(date_name) 148 | if digits_match: 149 | try: 150 | days_ago = int(digits_match.group()) 151 | except: 152 | days_ago = None 153 | 154 | if days_ago is None: 155 | logging.info( 156 | f'Oura ({self._name}): ' + 157 | 'Unknown day name `{date_name}`, using yesterday.') 158 | days_ago = 1 159 | 160 | return str(today - datetime.timedelta(days=days_ago)) 161 | 162 | def _get_date_type_by_name(self, date_name): 163 | """Gets the type of date format based in the date name. 164 | 165 | Args: 166 | date_name: Date for which to verify type. 167 | 168 | Returns: 169 | Date type(MonitoredDayType). 170 | """ 171 | if date_name == 'yesterday': 172 | return MonitoredDayType.YESTERDAY 173 | elif date_name in _FULL_WEEKDAY_NAMES: 174 | return MonitoredDayType.WEEKDAY 175 | elif 'd_ago' in date_name or 'days_ago' in date_name: 176 | return MonitoredDayType.DAYS_AGO 177 | else: 178 | return MonitoredDayType.UNKNOWN 179 | 180 | def _get_monitored_date_range(self): 181 | """Returns tuple containing start and end date based on monitored dates. 182 | 183 | Returns: 184 | (start_date, end_date) in YYYY-MM-DD 185 | """ 186 | sensor_dates = self._get_monitored_name_days() 187 | 188 | today_date = datetime.datetime.today().strftime('%Y-%m-%d') 189 | sensor_dates = sensor_dates.values() 190 | start_date = ( 191 | min(sensor_dates) if len(sensor_dates) > 0 else today_date) 192 | end_date = ( 193 | max(sensor_dates) if len(sensor_dates) > 0 else today_date) 194 | 195 | # Add an extra week to retrieve past week in case current week data is 196 | # missing. 197 | start_date = date_helper.add_days_to_string_date(start_date, -7) 198 | 199 | # Add an extra day to retrieve today's date in case of timezone difference. 200 | end_date = date_helper.add_days_to_string_date(end_date, 1) 201 | 202 | return (start_date, end_date) 203 | 204 | def _get_monitored_name_days(self): 205 | """Gets the date name of all monitored days. 206 | 207 | Returns: 208 | Map of date names to dates (YYYY-MM-DD) for monitored days. 209 | """ 210 | return { 211 | date_name: self._get_date_by_name(date_name) 212 | for date_name in self._monitored_dates 213 | } 214 | 215 | def _map_data_to_monitored_days(self, sensor_data, default_attributes=None): 216 | """Reads sensor data and maps it to the monitored dates, incl. backfill. 217 | 218 | Args: 219 | sensor_data: All parsed sensor data with daily breakdowns. 220 | default_attributes: Sensor information to use if no data is retrieved. 221 | 222 | Returns: 223 | sensor_data mapped to monitored_dates. 224 | """ 225 | sensor_dates = self._get_monitored_name_days() 226 | (start_date, _) = self._get_monitored_date_range() 227 | 228 | if not sensor_data: 229 | sensor_data = {} 230 | 231 | if not default_attributes: 232 | default_attributes = {} 233 | 234 | dated_attributes_map = {} 235 | for date_name, date_value in sensor_dates.items(): 236 | date_attributes = dict() 237 | date_attributes.update(default_attributes) 238 | date_attributes['day'] = date_value 239 | 240 | daily_data = sensor_data.get(date_value) 241 | date_name_title = date_name.title() 242 | 243 | # Check past dates to see if backfill is possible when missing data. 244 | backfill = 0 245 | original_date = date_value 246 | while (not daily_data 247 | and backfill < self._backfill 248 | and date_value >= start_date): 249 | date_value = self._get_backfill_date(date_name, date_value) 250 | if not date_value: 251 | break 252 | daily_data = sensor_data.get(date_value) 253 | backfill += 1 254 | 255 | if original_date != date_value: 256 | logging.warning( 257 | ( 258 | f'Oura ({self._name}): No Oura data found for ' 259 | f'{date_name_title} ({original_date}). Fetching {date_value} ' 260 | 'instead.' 261 | ) if date_value else ( 262 | f'Unable to find suitable backfill date. No data available.' 263 | ) 264 | ) 265 | 266 | if daily_data: 267 | date_attributes.update(daily_data) 268 | 269 | dated_attributes_map[date_name] = date_attributes 270 | 271 | return dated_attributes_map 272 | 273 | def _update_state(self, sensor_attributes): 274 | """Updates the state based on the sensor attributes. 275 | 276 | Args: 277 | sensor_attributes: Sensor attributes (before filtering). 278 | """ 279 | if not self._main_state_attribute: 280 | return 281 | 282 | if not self._monitored_dates: 283 | return 284 | 285 | first_monitored_date = self._monitored_dates[0] 286 | if not first_monitored_date: 287 | return 288 | 289 | first_date_attributes = sensor_attributes.get(first_monitored_date) 290 | if not first_date_attributes: 291 | return 292 | 293 | self._state = first_date_attributes.get(self._main_state_attribute) 294 | 295 | def _update(self): 296 | """Fetches new state data for the sensor.""" 297 | (start_date, end_date) = self._get_monitored_date_range() 298 | 299 | oura_data = self.get_sensor_data_from_api(start_date, end_date) 300 | sensor_data = self.parse_sensor_data(oura_data) 301 | 302 | if not sensor_data: 303 | sensor_data = {} 304 | 305 | dated_attributes = self._map_data_to_monitored_days( 306 | sensor_data, self._empty_sensor) 307 | 308 | # Update state must happen before filtering for monitored variables. 309 | self._update_state(dated_attributes) 310 | 311 | dated_attributes = self._filter_monitored_variables(dated_attributes) 312 | self._attributes = dated_attributes 313 | 314 | def filter_individual_data_point(self, data_point): 315 | """Filters an individual data point. 316 | 317 | If data must be filtered, this must be implemented by the child class. 318 | 319 | Args: 320 | data_point: Object for an individual day or data point. 321 | 322 | Returns: 323 | True, if data needs to be included. False, otherwise. 324 | """ 325 | return True 326 | 327 | def get_sensor_data_from_api(self, start_date, end_date): 328 | """Fetches data from the API for the sensor. 329 | 330 | Args: 331 | start_date: Start date in YYYY-MM-DD. 332 | end_date: End date in YYYY-MM-DD. 333 | 334 | Returns: 335 | JSON object with API data. 336 | """ 337 | return self._api.get_oura_data(self._api_endpoint, start_date, end_date) 338 | 339 | def parse_individual_data_point(self, data_point): 340 | """Parses the individual day or data point. 341 | 342 | If there are changes to the data point, they must be implemented by child. 343 | 344 | Args: 345 | data_point: Object for an individual day or data point. 346 | 347 | Returns: 348 | Modified data point with right parsed data. 349 | """ 350 | return data_point 351 | 352 | def parse_sensor_data(self, oura_data, data_param='data', day_param='day'): 353 | """Parses data from the API. 354 | 355 | Args: 356 | oura_data: Data from Oura API. 357 | data_param: Parameter where data is found. By default: 'data'. 358 | day_param: Parameter where date is found. By default: 'date'. 359 | 360 | Returns: 361 | Dictionary where key is the requested date and value is the 362 | Oura sensor data for that given day. 363 | """ 364 | if not oura_data or data_param not in oura_data: 365 | logging.error( 366 | f'Oura ({self._name}): Couldn\'t fetch data for Oura ring sensor.') 367 | return {} 368 | 369 | sensor_data = oura_data.get(data_param) 370 | if not sensor_data: 371 | return {} 372 | 373 | sensor_dict = {} 374 | for sensor_daily_data in sensor_data: 375 | sensor_daily_data = self.parse_individual_data_point(sensor_daily_data) 376 | if not sensor_daily_data: 377 | continue 378 | 379 | include_in_data = self.filter_individual_data_point(sensor_daily_data) 380 | if not include_in_data: 381 | continue 382 | 383 | sensor_date = sensor_daily_data.get(day_param) 384 | if not sensor_date: 385 | continue 386 | 387 | sensor_dict[sensor_date] = sensor_daily_data 388 | 389 | return sensor_dict 390 | -------------------------------------------------------------------------------- /custom_components/oura/sensor_base_dated_series.py: -------------------------------------------------------------------------------- 1 | """Provides a base OuraSensor class for date-series Oura endpoints.""" 2 | 3 | import logging 4 | from . import sensor_base_dated 5 | 6 | 7 | class OuraDatedSeriesSensor(sensor_base_dated.OuraDatedSensor): 8 | """Representation of an Oura Ring sensor with series daily data. 9 | 10 | Attributes: 11 | name: name of the sensor. 12 | state: state of the sensor. 13 | extra_state_attributes: attributes of the sensor. 14 | 15 | Methods: 16 | async_update: updates sensor data. 17 | """ 18 | 19 | def __init__(self, config, hass, sensor_config=None): 20 | """Initializes the sensor. 21 | 22 | Args: 23 | config: Platform configuration. 24 | hass: Home-Assistant object. 25 | sensor_config: Sub-section of config holding the particular sensor info. 26 | 27 | Methods: 28 | parse_sensor_data: Parses data from API. 29 | """ 30 | super(OuraDatedSeriesSensor, self).__init__(config, hass, sensor_config) 31 | self._sort_key = 'start_datetime' 32 | 33 | def _filter_monitored_variables(self, sensor_data): 34 | """Filters the sensor data to only contain monitored variables. 35 | 36 | Args: 37 | sensor_data: Map of dates to sensor data. 38 | 39 | Returns: 40 | Same sensor_data map but filtered to only contain monitored variables. 41 | """ 42 | data = {} 43 | data.update(sensor_data) 44 | 45 | if not data: 46 | return data 47 | 48 | for date_series in list(data.values()): 49 | for daily_data_point in date_series: 50 | for variable in list(daily_data_point.keys()): 51 | if variable not in self._monitored_variables: 52 | del daily_data_point[variable] 53 | return data 54 | 55 | def _map_data_to_monitored_days(self, sensor_data, default_attributes=None): 56 | """Reads sensor data and maps it to the monitored dates, incl. backfill. 57 | 58 | Args: 59 | sensor_data: All parsed sensor data with daily breakdowns. 60 | default_attributes: Sensor information to use if no data is retrieved. 61 | 62 | Returns: 63 | sensor_data mapped to monitored_dates. 64 | """ 65 | sensor_dates = self._get_monitored_name_days() 66 | (start_date, _) = self._get_monitored_date_range() 67 | 68 | if not sensor_data: 69 | sensor_data = {} 70 | 71 | if not default_attributes: 72 | default_attributes = {} 73 | 74 | dated_attributes_map = {} 75 | for date_name, date_value in sensor_dates.items(): 76 | date_values = [] 77 | 78 | daily_data = sensor_data.get(date_value) 79 | date_name_title = date_name.title() 80 | 81 | # Check past dates to see if backfill is possible when missing data. 82 | backfill = 0 83 | original_date = date_value 84 | while (not daily_data 85 | and backfill < self._backfill 86 | and date_value >= start_date): 87 | date_value = self._get_backfill_date(date_name, date_value) 88 | if not date_value: 89 | break 90 | daily_data = sensor_data.get(date_value) 91 | backfill += 1 92 | 93 | if original_date != date_value: 94 | logging.warning( 95 | ( 96 | f'Oura ({self._name}): No Oura data found for ' 97 | f'{date_name_title} ({original_date}). Fetching {date_value} ' 98 | 'instead.' 99 | ) if date_value else ( 100 | f'Unable to find suitable backfill date. No data available.' 101 | ) 102 | ) 103 | 104 | if not daily_data: 105 | daily_data = [self._empty_sensor] 106 | 107 | if not type(daily_data) == list: 108 | daily_data = [daily_data] 109 | 110 | for series_data in daily_data: 111 | series_attributes = dict() 112 | series_attributes.update(default_attributes) 113 | series_attributes['day'] = date_value 114 | series_attributes.update(series_data) 115 | date_values.append(series_attributes) 116 | 117 | if date_name not in dated_attributes_map: 118 | dated_attributes_map[date_name] = [] 119 | 120 | dated_attributes_map[date_name].extend(date_values) 121 | 122 | return dated_attributes_map 123 | 124 | def _update_state(self, sensor_attributes): 125 | """Updates the state based on the sensor attributes. 126 | 127 | Args: 128 | sensor_attributes: Sensor attributes (before filtering). 129 | """ 130 | if not self._main_state_attribute: 131 | return 132 | 133 | if not self._monitored_dates: 134 | return 135 | 136 | first_monitored_date = self._monitored_dates[0] 137 | if not first_monitored_date: 138 | return 139 | 140 | first_date_attributes = sensor_attributes.get(first_monitored_date) 141 | if not first_date_attributes: 142 | return 143 | 144 | first_date_attributes_copy = [] 145 | first_date_attributes_copy.extend(first_date_attributes) 146 | sorted( 147 | first_date_attributes_copy, 148 | key=lambda data_point: data_point[self._sort_key]) 149 | 150 | first_series_attribute = first_date_attributes_copy[0] 151 | if not first_series_attribute: 152 | return 153 | 154 | self._state = first_series_attribute.get(self._main_state_attribute) 155 | 156 | def parse_sensor_data(self, oura_data, data_param='data', day_param='day'): 157 | """Parses data from the API. 158 | 159 | Args: 160 | oura_data: Data from Oura API. 161 | data_param: Parameter where data is found. By default: 'data'. 162 | day_param: Parameter where date is found. By default: 'date'. 163 | 164 | Returns: 165 | Dictionary where key is the requested date and value is the 166 | Oura sensor data for that given day. 167 | """ 168 | if not oura_data or data_param not in oura_data: 169 | logging.error( 170 | f'Oura ({self._name}): Couldn\'t fetch data for Oura ring sensor.') 171 | return {} 172 | 173 | sensor_data = oura_data.get(data_param) 174 | if not sensor_data: 175 | return {} 176 | 177 | sensor_dict = {} 178 | for sensor_daily_data in sensor_data: 179 | sensor_daily_data = self.parse_individual_data_point(sensor_daily_data) 180 | if not sensor_daily_data: 181 | continue 182 | 183 | include_in_data = self.filter_individual_data_point(sensor_daily_data) 184 | if not include_in_data: 185 | continue 186 | 187 | sensor_date = sensor_daily_data.get(day_param) 188 | if not sensor_date: 189 | continue 190 | 191 | if sensor_date not in sensor_dict: 192 | sensor_dict[sensor_date] = [] 193 | 194 | sensor_dict[sensor_date].append(sensor_daily_data) 195 | 196 | return sensor_dict 197 | -------------------------------------------------------------------------------- /custom_components/oura/sensor_bedtime.py: -------------------------------------------------------------------------------- 1 | """Provides a bedtime sensor.""" 2 | 3 | import voluptuous as vol 4 | from homeassistant import const 5 | from homeassistant.helpers import config_validation as cv 6 | from . import api 7 | from . import const as oura_const 8 | from . import sensor_base_dated 9 | from .helpers import date_helper 10 | 11 | # Sensor configuration 12 | CONF_KEY_NAME = 'bedtime' 13 | 14 | _DEFAULT_NAME = 'oura_bedtime' 15 | 16 | _DEFAULT_ATTRIBUTE_STATE = 'bedtime_window_start' 17 | 18 | _DEFAULT_MONITORED_VARIABLES = [ 19 | 'bedtime_window_start', 20 | 'bedtime_window_end', 21 | 'day', 22 | ] 23 | 24 | _SUPPORTED_MONITORED_VARIABLES = [ 25 | 'bedtime_window_start', 26 | 'bedtime_window_end', 27 | 'day', 28 | ] 29 | 30 | CONF_SCHEMA = { 31 | vol.Optional(const.CONF_NAME, default=_DEFAULT_NAME): cv.string, 32 | 33 | vol.Optional( 34 | oura_const.CONF_ATTRIBUTE_STATE, 35 | default=_DEFAULT_ATTRIBUTE_STATE 36 | ): vol.In(_SUPPORTED_MONITORED_VARIABLES), 37 | 38 | vol.Optional( 39 | oura_const.CONF_MONITORED_DATES, 40 | default=oura_const.DEFAULT_MONITORED_DATES 41 | ): cv.ensure_list, 42 | 43 | vol.Optional( 44 | const.CONF_MONITORED_VARIABLES, 45 | default=_DEFAULT_MONITORED_VARIABLES 46 | ): vol.All(cv.ensure_list, [vol.In(_SUPPORTED_MONITORED_VARIABLES)]), 47 | 48 | vol.Optional( 49 | oura_const.CONF_BACKFILL, 50 | default=oura_const.DEFAULT_BACKFILL 51 | ): cv.positive_int, 52 | } 53 | 54 | _EMPTY_SENSOR_ATTRIBUTE = { 55 | variable: None for variable in _SUPPORTED_MONITORED_VARIABLES 56 | } 57 | 58 | 59 | class OuraBedtimeSensor(sensor_base_dated.OuraDatedSensor): 60 | """Representation of an Oura Ring Bedtime sensor. 61 | 62 | Attributes: 63 | name: name of the sensor. 64 | state: state of the sensor. 65 | extra_state_attributes: attributes of the sensor. 66 | 67 | Methods: 68 | async_update: updates sensor data. 69 | """ 70 | 71 | def __init__(self, config, hass): 72 | """Initializes the sensor.""" 73 | bedtime_config = ( 74 | config.get(const.CONF_SENSORS, {}).get(CONF_KEY_NAME, {})) 75 | super(OuraBedtimeSensor, self).__init__(config, hass, bedtime_config) 76 | 77 | self._api_endpoint = api.OuraEndpoints.BEDTIME 78 | self._empty_sensor = _EMPTY_SENSOR_ATTRIBUTE 79 | 80 | def parse_individual_data_point(self, data_point): 81 | """Parses the individual day or data point. 82 | 83 | Args: 84 | data_point: Object for an individual day or data point. 85 | 86 | Returns: 87 | Modified data point with right parsed data. 88 | """ 89 | data_point_copy = {} 90 | data_point_copy.update(data_point) 91 | 92 | data_point_copy['day'] = data_point_copy['date'] 93 | 94 | bedtime_window = data_point_copy.get('bedtime_window', {}) 95 | 96 | start_diff = bedtime_window['start'] 97 | start_hour = date_helper.add_time_to_string_time('00:00', start_diff) 98 | data_point_copy['bedtime_window_start'] = start_hour 99 | 100 | end_diff = bedtime_window['end'] 101 | end_hour = date_helper.add_time_to_string_time('00:00', end_diff) 102 | data_point_copy['bedtime_window_end'] = end_hour 103 | 104 | del data_point_copy['bedtime_window'] 105 | 106 | return data_point_copy 107 | 108 | def parse_sensor_data(self, oura_data): 109 | """Processes bedtime data into a dictionary. 110 | 111 | Args: 112 | oura_data: Bedtime data in list format from Oura API. 113 | 114 | Returns: 115 | Dictionary where key is the requested summary_date and value is the 116 | Oura bedtime data for that given day. 117 | """ 118 | return super(OuraBedtimeSensor, self).parse_sensor_data( 119 | oura_data, 'ideal_bedtimes') 120 | -------------------------------------------------------------------------------- /custom_components/oura/sensor_heart_rate.py: -------------------------------------------------------------------------------- 1 | """Provides a heart rate sensor.""" 2 | 3 | import datetime 4 | import voluptuous as vol 5 | from homeassistant import const 6 | from homeassistant.helpers import config_validation as cv 7 | from . import api 8 | from . import const as oura_const 9 | from . import sensor_base_dated_series 10 | 11 | # Sensor configuration 12 | CONF_KEY_NAME = 'heart_rate' 13 | 14 | _DEFAULT_NAME = 'oura_heart_rate' 15 | 16 | _DEFAULT_ATTRIBUTE_STATE = 'bpm' 17 | 18 | _DEFAULT_MONITORED_VARIABLES = [ 19 | 'day', 20 | 'bpm', 21 | 'source', 22 | 'timestamp', 23 | ] 24 | 25 | _SUPPORTED_MONITORED_VARIABLES = [ 26 | 'day', 27 | 'bpm', 28 | 'source', 29 | 'timestamp', 30 | ] 31 | 32 | CONF_SCHEMA = { 33 | vol.Optional(const.CONF_NAME, default=_DEFAULT_NAME): cv.string, 34 | 35 | vol.Optional( 36 | oura_const.CONF_ATTRIBUTE_STATE, 37 | default=_DEFAULT_ATTRIBUTE_STATE 38 | ): vol.In(_SUPPORTED_MONITORED_VARIABLES), 39 | 40 | vol.Optional( 41 | oura_const.CONF_MONITORED_DATES, 42 | default=oura_const.DEFAULT_MONITORED_DATES 43 | ): cv.ensure_list, 44 | 45 | vol.Optional( 46 | const.CONF_MONITORED_VARIABLES, 47 | default=_DEFAULT_MONITORED_VARIABLES 48 | ): vol.All(cv.ensure_list, [vol.In(_SUPPORTED_MONITORED_VARIABLES)]), 49 | 50 | vol.Optional( 51 | oura_const.CONF_BACKFILL, 52 | default=oura_const.DEFAULT_BACKFILL 53 | ): cv.positive_int, 54 | } 55 | 56 | _EMPTY_SENSOR_ATTRIBUTE = { 57 | variable: None for variable in _SUPPORTED_MONITORED_VARIABLES 58 | } 59 | 60 | 61 | class OuraHeartRateSensor(sensor_base_dated_series.OuraDatedSeriesSensor): 62 | """Representation of an Oura Ring Heart Rate sensor. 63 | 64 | Attributes: 65 | name: name of the sensor. 66 | state: state of the sensor. 67 | extra_state_attributes: attributes of the sensor. 68 | 69 | Methods: 70 | async_update: updates sensor data. 71 | """ 72 | 73 | def __init__(self, config, hass): 74 | """Initializes the sensor.""" 75 | sessions_config = ( 76 | config.get(const.CONF_SENSORS, {}).get(CONF_KEY_NAME, {})) 77 | super(OuraHeartRateSensor, self).__init__(config, hass, sessions_config) 78 | 79 | self._api_endpoint = api.OuraEndpoints.HEART_RATE 80 | self._empty_sensor = _EMPTY_SENSOR_ATTRIBUTE 81 | self._sort_key = 'timestamp' 82 | 83 | def get_sensor_data_from_api(self, start_date, end_date): 84 | """Fetches data from the API for the sensor. 85 | 86 | Args: 87 | start_date: Start date in YYYY-MM-DD. 88 | end_date: End date in YYYY-MM-DD. 89 | 90 | Returns: 91 | JSON object with API data. 92 | """ 93 | start_date_parsed = datetime.datetime.strptime(start_date, "%Y-%m-%d") 94 | start_date_parsed = start_date_parsed.replace( 95 | hour=0, 96 | minute=0, 97 | second=0, 98 | ) 99 | start_time = start_date_parsed.isoformat() 100 | 101 | end_date_parsed = datetime.datetime.strptime(end_date, "%Y-%m-%d") 102 | end_date_parsed = end_date_parsed.replace( 103 | hour=23, 104 | minute=59, 105 | second=59, 106 | ) 107 | end_time = end_date_parsed.isoformat() 108 | 109 | return self._api.get_oura_data(self._api_endpoint, start_time, end_time) 110 | 111 | def parse_individual_data_point(self, data_point): 112 | """Parses the individual day or data point. 113 | 114 | Args: 115 | data_point: Object for an individual day or data point. 116 | 117 | Returns: 118 | Modified data point with right parsed data. 119 | """ 120 | data_point_copy = {} 121 | data_point_copy.update(data_point) 122 | 123 | timestamp = data_point_copy.get('timestamp') 124 | if timestamp: 125 | parsed_timestamp = datetime.datetime.fromisoformat(timestamp) 126 | day = parsed_timestamp.strftime('%Y-%m-%d') 127 | data_point_copy['day'] = day 128 | 129 | return data_point_copy 130 | -------------------------------------------------------------------------------- /custom_components/oura/sensor_readiness.py: -------------------------------------------------------------------------------- 1 | """Provides a readiness sensor.""" 2 | 3 | import voluptuous as vol 4 | from homeassistant import const 5 | from homeassistant.helpers import config_validation as cv 6 | from . import api 7 | from . import const as oura_const 8 | from . import sensor_base_dated 9 | 10 | # Sensor configuration 11 | CONF_KEY_NAME = 'readiness' 12 | 13 | _DEFAULT_NAME = 'oura_readiness' 14 | 15 | _DEFAULT_ATTRIBUTE_STATE = 'score' 16 | 17 | _DEFAULT_MONITORED_VARIABLES = [ 18 | 'activity_balance', 19 | 'body_temperature', 20 | 'hrv_balance', 21 | 'previous_day_activity', 22 | 'previous_night', 23 | 'day', 24 | 'recovery_index', 25 | 'resting_heart_rate', 26 | 'score', 27 | 'sleep_balance', 28 | ] 29 | 30 | _SUPPORTED_MONITORED_VARIABLES = [ 31 | 'activity_balance', 32 | 'body_temperature', 33 | 'day', 34 | 'hrv_balance', 35 | 'previous_day_activity', 36 | 'previous_night', 37 | 'recovery_index', 38 | 'resting_heart_rate', 39 | 'sleep_balance', 40 | 'score', 41 | 'temperature_deviation', 42 | 'temperature_trend_deviation', 43 | 'timestamp', 44 | ] 45 | 46 | CONF_SCHEMA = { 47 | vol.Optional(const.CONF_NAME, default=_DEFAULT_NAME): cv.string, 48 | 49 | vol.Optional( 50 | oura_const.CONF_ATTRIBUTE_STATE, 51 | default=_DEFAULT_ATTRIBUTE_STATE 52 | ): vol.In(_SUPPORTED_MONITORED_VARIABLES), 53 | 54 | vol.Optional( 55 | oura_const.CONF_MONITORED_DATES, 56 | default=oura_const.DEFAULT_MONITORED_DATES 57 | ): cv.ensure_list, 58 | 59 | vol.Optional( 60 | const.CONF_MONITORED_VARIABLES, 61 | default=_DEFAULT_MONITORED_VARIABLES 62 | ): vol.All(cv.ensure_list, [vol.In(_SUPPORTED_MONITORED_VARIABLES)]), 63 | 64 | vol.Optional( 65 | oura_const.CONF_BACKFILL, 66 | default=oura_const.DEFAULT_BACKFILL 67 | ): cv.positive_int, 68 | } 69 | 70 | _EMPTY_SENSOR_ATTRIBUTE = { 71 | variable: None for variable in _SUPPORTED_MONITORED_VARIABLES 72 | } 73 | 74 | 75 | class OuraReadinessSensor(sensor_base_dated.OuraDatedSensor): 76 | """Representation of an Oura Ring Readiness sensor. 77 | 78 | Attributes: 79 | name: name of the sensor. 80 | state: state of the sensor. 81 | extra_state_attributes: attributes of the sensor. 82 | 83 | Methods: 84 | async_update: updates sensor data. 85 | """ 86 | 87 | def __init__(self, config, hass): 88 | """Initializes the sensor.""" 89 | readiness_config = ( 90 | config.get(const.CONF_SENSORS, {}).get(CONF_KEY_NAME, {})) 91 | super(OuraReadinessSensor, self).__init__(config, hass, readiness_config) 92 | 93 | self._api_endpoint = api.OuraEndpoints.READINESS 94 | self._empty_sensor = _EMPTY_SENSOR_ATTRIBUTE 95 | 96 | def parse_individual_data_point(self, data_point): 97 | """Parses the individual day or data point. 98 | 99 | Args: 100 | data_point: Object for an individual day or data point. 101 | 102 | Returns: 103 | Modified data point with right parsed data. 104 | """ 105 | data_point_copy = {} 106 | data_point_copy.update(data_point) 107 | 108 | contributors = data_point_copy.get('contributors', {}) 109 | data_point_copy.update(contributors) 110 | del data_point_copy['contributors'] 111 | 112 | return data_point_copy 113 | -------------------------------------------------------------------------------- /custom_components/oura/sensor_sessions.py: -------------------------------------------------------------------------------- 1 | """Provides a sessions sensor.""" 2 | 3 | import voluptuous as vol 4 | from homeassistant import const 5 | from homeassistant.helpers import config_validation as cv 6 | from . import api 7 | from . import const as oura_const 8 | from . import sensor_base_dated_series 9 | 10 | # Sensor configuration 11 | CONF_KEY_NAME = 'sessions' 12 | 13 | _DEFAULT_NAME = 'oura_sessions' 14 | 15 | _DEFAULT_ATTRIBUTE_STATE = 'type' 16 | 17 | _DEFAULT_MONITORED_VARIABLES = [ 18 | 'day', 19 | 'start_datetime', 20 | 'end_datetime', 21 | 'type', 22 | 'heart_rate', 23 | 'motion_count', 24 | ] 25 | 26 | _SUPPORTED_MONITORED_VARIABLES = [ 27 | 'day', 28 | 'start_datetime', 29 | 'end_datetime', 30 | 'type', 31 | 'heart_rate', 32 | 'heart_rate_variability', 33 | 'mood', 34 | 'motion_count', 35 | ] 36 | 37 | CONF_SCHEMA = { 38 | vol.Optional(const.CONF_NAME, default=_DEFAULT_NAME): cv.string, 39 | 40 | vol.Optional( 41 | oura_const.CONF_ATTRIBUTE_STATE, 42 | default=_DEFAULT_ATTRIBUTE_STATE 43 | ): vol.In(_SUPPORTED_MONITORED_VARIABLES), 44 | 45 | vol.Optional( 46 | oura_const.CONF_MONITORED_DATES, 47 | default=oura_const.DEFAULT_MONITORED_DATES 48 | ): cv.ensure_list, 49 | 50 | vol.Optional( 51 | const.CONF_MONITORED_VARIABLES, 52 | default=_DEFAULT_MONITORED_VARIABLES 53 | ): vol.All(cv.ensure_list, [vol.In(_SUPPORTED_MONITORED_VARIABLES)]), 54 | 55 | vol.Optional( 56 | oura_const.CONF_BACKFILL, 57 | default=oura_const.DEFAULT_BACKFILL 58 | ): cv.positive_int, 59 | } 60 | 61 | _EMPTY_SENSOR_ATTRIBUTE = { 62 | variable: None for variable in _SUPPORTED_MONITORED_VARIABLES 63 | } 64 | 65 | 66 | class OuraSessionsSensor(sensor_base_dated_series.OuraDatedSeriesSensor): 67 | """Representation of an Oura Ring Sessions sensor. 68 | 69 | Attributes: 70 | name: name of the sensor. 71 | state: state of the sensor. 72 | extra_state_attributes: attributes of the sensor. 73 | 74 | Methods: 75 | async_update: updates sensor data. 76 | """ 77 | 78 | def __init__(self, config, hass): 79 | """Initializes the sensor.""" 80 | sessions_config = ( 81 | config.get(const.CONF_SENSORS, {}).get(CONF_KEY_NAME, {})) 82 | super(OuraSessionsSensor, self).__init__(config, hass, sessions_config) 83 | 84 | self._api_endpoint = api.OuraEndpoints.SESSIONS 85 | self._empty_sensor = _EMPTY_SENSOR_ATTRIBUTE 86 | -------------------------------------------------------------------------------- /custom_components/oura/sensor_sleep.py: -------------------------------------------------------------------------------- 1 | """Provides a sleep sensor.""" 2 | 3 | import voluptuous as vol 4 | 5 | from dateutil import parser 6 | from homeassistant import const 7 | from homeassistant.helpers import config_validation as cv 8 | from . import api 9 | from . import const as oura_const 10 | from . import sensor_base_dated 11 | from .helpers import date_helper 12 | 13 | # Sensor configuration 14 | CONF_KEY_NAME = 'sleep' 15 | 16 | _DEFAULT_NAME = 'oura_sleep' 17 | 18 | _DEFAULT_ATTRIBUTE_STATE = 'efficiency' 19 | 20 | _DEFAULT_MONITORED_VARIABLES = [ 21 | 'average_breath', 22 | 'average_heart_rate', 23 | 'awake_duration_in_hours', 24 | 'bedtime_start_hour', 25 | 'bedtime_end_hour', 26 | 'day', 27 | 'deep_sleep_duration_in_hours', 28 | 'in_bed_duration_in_hours', 29 | 'light_sleep_duration_in_hours', 30 | 'lowest_heart_rate', 31 | 'rem_sleep_duration_in_hours', 32 | 'total_sleep_duration_in_hours', 33 | ] 34 | 35 | _SUPPORTED_MONITORED_VARIABLES = [ 36 | 'average_breath', 37 | 'average_heart_rate', 38 | 'average_hrv', 39 | 'day', 40 | 'awake_time', 41 | 'awake_duration_in_hours', 42 | 'bedtime_end', 43 | 'bedtime_end_hour', 44 | 'bedtime_start', 45 | 'bedtime_start_hour', 46 | 'deep_sleep_duration', 47 | 'deep_sleep_duration_in_hours', 48 | 'efficiency', 49 | 'heart_rate', 50 | 'hrv', 51 | 'in_bed_duration_in_hours', 52 | 'latency', 53 | 'light_sleep_duration', 54 | 'light_sleep_duration_in_hours', 55 | 'low_battery_alert', 56 | 'lowest_heart_rate', 57 | 'movement_30_sec', 58 | 'period', 59 | 'readiness_score_delta', 60 | 'rem_sleep_duration', 61 | 'rem_sleep_duration_in_hours', 62 | 'restless_periods', 63 | 'sleep_phase_5_min', 64 | 'sleep_score_delta', 65 | 'time_in_bed', 66 | 'total_sleep_duration', 67 | 'total_sleep_duration_in_hours', 68 | 'type', 69 | ] 70 | 71 | CONF_SCHEMA = { 72 | vol.Optional(const.CONF_NAME, default=_DEFAULT_NAME): cv.string, 73 | 74 | vol.Optional( 75 | oura_const.CONF_ATTRIBUTE_STATE, 76 | default=_DEFAULT_ATTRIBUTE_STATE 77 | ): vol.In(_SUPPORTED_MONITORED_VARIABLES), 78 | 79 | vol.Optional( 80 | oura_const.CONF_MONITORED_DATES, 81 | default=oura_const.DEFAULT_MONITORED_DATES 82 | ): cv.ensure_list, 83 | 84 | vol.Optional( 85 | const.CONF_MONITORED_VARIABLES, 86 | default=_DEFAULT_MONITORED_VARIABLES 87 | ): vol.All(cv.ensure_list, [vol.In(_SUPPORTED_MONITORED_VARIABLES)]), 88 | 89 | vol.Optional( 90 | oura_const.CONF_BACKFILL, 91 | default=oura_const.DEFAULT_BACKFILL 92 | ): cv.positive_int, 93 | } 94 | 95 | _EMPTY_SENSOR_ATTRIBUTE = { 96 | variable: None for variable in _SUPPORTED_MONITORED_VARIABLES 97 | } 98 | 99 | 100 | class OuraSleepSensor(sensor_base_dated.OuraDatedSensor): 101 | """Representation of an Oura Ring Sleep sensor. 102 | 103 | Attributes: 104 | name: name of the sensor. 105 | state: state of the sensor. 106 | extra_state_attributes: attributes of the sensor. 107 | 108 | Methods: 109 | async_update: updates sensor data. 110 | """ 111 | 112 | def __init__(self, config, hass): 113 | """Initializes the sensor.""" 114 | sleep_config = config.get(const.CONF_SENSORS, {}).get(CONF_KEY_NAME, {}) 115 | super(OuraSleepSensor, self).__init__(config, hass, sleep_config) 116 | 117 | self._api_endpoint = api.OuraEndpoints.SLEEP_PERIODS 118 | self._empty_sensor = _EMPTY_SENSOR_ATTRIBUTE 119 | 120 | def filter_individual_data_point(self, data_point): 121 | """Filters an individual data point. 122 | 123 | If data must be filtered, this must be implemented by the child class. 124 | 125 | Args: 126 | data_point: Object for an individual day or data point. 127 | 128 | Returns: 129 | True, if data needs to be included. False, otherwise. 130 | """ 131 | return data_point.get('type') == 'long_sleep' 132 | 133 | def parse_individual_data_point(self, data_point): 134 | """Parses the individual day or data point. 135 | 136 | Args: 137 | data_point: Object for an individual day or data point. 138 | 139 | Returns: 140 | Modified data_point with right parsed data. 141 | """ 142 | data_point_copy = {} 143 | data_point_copy.update(data_point) 144 | 145 | bedtime_start = parser.parse(data_point_copy.get('bedtime_start')) 146 | bedtime_end = parser.parse(data_point_copy.get('bedtime_end')) 147 | 148 | # Derived metrics. 149 | data_point_copy.update({ 150 | # HH:MM at which you went bed. 151 | 'bedtime_start_hour': bedtime_start.strftime('%H:%M'), 152 | # HH:MM at which you woke up. 153 | 'bedtime_end_hour': bedtime_end.strftime('%H:%M'), 154 | # Hours in deep sleep. 155 | 'deep_sleep_duration_in_hours': date_helper.seconds_to_hours( 156 | data_point_copy.get('deep_sleep_duration')), 157 | # Hours in REM sleep. 158 | 'rem_sleep_duration_in_hours': date_helper.seconds_to_hours( 159 | data_point_copy.get('rem_sleep_duration')), 160 | # Hours in light sleep. 161 | 'light_sleep_duration_in_hours': date_helper.seconds_to_hours( 162 | data_point_copy.get('light_sleep_duration')), 163 | # Hours sleeping: deep + rem + light. 164 | 'total_sleep_duration_in_hours': date_helper.seconds_to_hours( 165 | data_point_copy.get('total_sleep_duration')), 166 | # Hours awake. 167 | 'awake_duration_in_hours': date_helper.seconds_to_hours( 168 | data_point_copy.get('awake_time')), 169 | # Hours in bed: sleep + awake. 170 | 'in_bed_duration_in_hours': date_helper.seconds_to_hours( 171 | data_point_copy.get('time_in_bed')), 172 | }) 173 | 174 | return data_point_copy 175 | -------------------------------------------------------------------------------- /custom_components/oura/sensor_sleep_periods.py: -------------------------------------------------------------------------------- 1 | """Provides a sleep periods sensor.""" 2 | 3 | import voluptuous as vol 4 | from dateutil import parser 5 | from homeassistant import const 6 | from homeassistant.helpers import config_validation as cv 7 | from . import api 8 | from . import const as oura_const 9 | from . import sensor_base_dated_series 10 | from .helpers import date_helper 11 | 12 | # Sensor configuration 13 | CONF_KEY_NAME = 'sleep_periods' 14 | 15 | _DEFAULT_NAME = 'oura_sleep_periods' 16 | 17 | _DEFAULT_ATTRIBUTE_STATE = 'efficiency' 18 | 19 | _DEFAULT_MONITORED_VARIABLES = [ 20 | 'average_breath', 21 | 'average_heart_rate', 22 | 'bedtime_start_hour', 23 | 'bedtime_end_hour', 24 | 'day', 25 | 'total_sleep_duration_in_hours', 26 | 'type', 27 | ] 28 | 29 | _SUPPORTED_MONITORED_VARIABLES = [ 30 | 'average_breath', 31 | 'average_heart_rate', 32 | 'average_hrv', 33 | 'day', 34 | 'awake_time', 35 | 'awake_duration_in_hours', 36 | 'bedtime_end', 37 | 'bedtime_end_hour', 38 | 'bedtime_start', 39 | 'bedtime_start_hour', 40 | 'deep_sleep_duration', 41 | 'deep_sleep_duration_in_hours', 42 | 'efficiency', 43 | 'heart_rate', 44 | 'hrv', 45 | 'in_bed_duration_in_hours', 46 | 'latency', 47 | 'light_sleep_duration', 48 | 'light_sleep_duration_in_hours', 49 | 'low_battery_alert', 50 | 'lowest_heart_rate', 51 | 'movement_30_sec', 52 | 'period', 53 | 'readiness_score_delta', 54 | 'rem_sleep_duration', 55 | 'rem_sleep_duration_in_hours', 56 | 'restless_periods', 57 | 'sleep_phase_5_min', 58 | 'sleep_score_delta', 59 | 'time_in_bed', 60 | 'total_sleep_duration', 61 | 'total_sleep_duration_in_hours', 62 | 'type', 63 | ] 64 | 65 | CONF_SCHEMA = { 66 | vol.Optional(const.CONF_NAME, default=_DEFAULT_NAME): cv.string, 67 | 68 | vol.Optional( 69 | oura_const.CONF_ATTRIBUTE_STATE, 70 | default=_DEFAULT_ATTRIBUTE_STATE 71 | ): vol.In(_SUPPORTED_MONITORED_VARIABLES), 72 | 73 | vol.Optional( 74 | oura_const.CONF_MONITORED_DATES, 75 | default=oura_const.DEFAULT_MONITORED_DATES 76 | ): cv.ensure_list, 77 | 78 | vol.Optional( 79 | const.CONF_MONITORED_VARIABLES, 80 | default=_DEFAULT_MONITORED_VARIABLES 81 | ): vol.All(cv.ensure_list, [vol.In(_SUPPORTED_MONITORED_VARIABLES)]), 82 | 83 | vol.Optional( 84 | oura_const.CONF_BACKFILL, 85 | default=oura_const.DEFAULT_BACKFILL 86 | ): cv.positive_int, 87 | } 88 | 89 | _EMPTY_SENSOR_ATTRIBUTE = { 90 | variable: None for variable in _SUPPORTED_MONITORED_VARIABLES 91 | } 92 | 93 | 94 | class OuraSleepPeriodsSensor(sensor_base_dated_series.OuraDatedSeriesSensor): 95 | """Representation of an Oura Ring Sleep Periods sensor. 96 | 97 | Attributes: 98 | name: name of the sensor. 99 | state: state of the sensor. 100 | extra_state_attributes: attributes of the sensor. 101 | 102 | Methods: 103 | async_update: updates sensor data. 104 | """ 105 | 106 | def __init__(self, config, hass): 107 | """Initializes the sensor.""" 108 | sleep_config = config.get(const.CONF_SENSORS, {}).get(CONF_KEY_NAME, {}) 109 | super(OuraSleepPeriodsSensor, self).__init__(config, hass, sleep_config) 110 | 111 | self._api_endpoint = api.OuraEndpoints.SLEEP_PERIODS 112 | self._empty_sensor = _EMPTY_SENSOR_ATTRIBUTE 113 | self._sort_key = 'bedtime_start' 114 | 115 | def parse_individual_data_point(self, data_point): 116 | """Parses the individual day or data point. 117 | 118 | Args: 119 | data_point: Object for an individual day or data point. 120 | 121 | Returns: 122 | Modified data_point with right parsed data. 123 | """ 124 | data_point_copy = {} 125 | data_point_copy.update(data_point) 126 | 127 | bedtime_start = parser.parse(data_point_copy.get('bedtime_start')) 128 | bedtime_end = parser.parse(data_point_copy.get('bedtime_end')) 129 | 130 | # Derived metrics. 131 | data_point_copy.update({ 132 | # HH:MM at which you went bed. 133 | 'bedtime_start_hour': bedtime_start.strftime('%H:%M'), 134 | # HH:MM at which you woke up. 135 | 'bedtime_end_hour': bedtime_end.strftime('%H:%M'), 136 | # Hours in deep sleep. 137 | 'deep_sleep_duration_in_hours': date_helper.seconds_to_hours( 138 | data_point_copy.get('deep_sleep_duration')), 139 | # Hours in REM sleep. 140 | 'rem_sleep_duration_in_hours': date_helper.seconds_to_hours( 141 | data_point_copy.get('rem_sleep_duration')), 142 | # Hours in light sleep. 143 | 'light_sleep_duration_in_hours': date_helper.seconds_to_hours( 144 | data_point_copy.get('light_sleep_duration')), 145 | # Hours sleeping: deep + rem + light. 146 | 'total_sleep_duration_in_hours': date_helper.seconds_to_hours( 147 | data_point_copy.get('total_sleep_duration')), 148 | # Hours awake. 149 | 'awake_duration_in_hours': date_helper.seconds_to_hours( 150 | data_point_copy.get('awake_time')), 151 | # Hours in bed: sleep + awake. 152 | 'in_bed_duration_in_hours': date_helper.seconds_to_hours( 153 | data_point_copy.get('time_in_bed')), 154 | }) 155 | 156 | return data_point_copy 157 | -------------------------------------------------------------------------------- /custom_components/oura/sensor_sleep_score.py: -------------------------------------------------------------------------------- 1 | """Provides a sleep score sensor.""" 2 | 3 | import voluptuous as vol 4 | from homeassistant import const 5 | from homeassistant.helpers import config_validation as cv 6 | from . import api 7 | from . import const as oura_const 8 | from . import sensor_base_dated 9 | 10 | # Sensor configuration 11 | CONF_KEY_NAME = 'sleep_score' 12 | 13 | _DEFAULT_NAME = 'oura_sleep_score' 14 | 15 | _DEFAULT_ATTRIBUTE_STATE = 'score' 16 | 17 | _DEFAULT_MONITORED_VARIABLES = [ 18 | 'day', 19 | 'score', 20 | ] 21 | 22 | _SUPPORTED_MONITORED_VARIABLES = [ 23 | 'day', 24 | 'deep_sleep', 25 | 'efficiency', 26 | 'latency', 27 | 'rem_sleep', 28 | 'restfulness', 29 | 'score', 30 | 'timing', 31 | 'timestamp', 32 | 'total_sleep', 33 | ] 34 | 35 | CONF_SCHEMA = { 36 | vol.Optional(const.CONF_NAME, default=_DEFAULT_NAME): cv.string, 37 | 38 | vol.Optional( 39 | oura_const.CONF_ATTRIBUTE_STATE, 40 | default=_DEFAULT_ATTRIBUTE_STATE 41 | ): vol.In(_SUPPORTED_MONITORED_VARIABLES), 42 | 43 | vol.Optional( 44 | oura_const.CONF_MONITORED_DATES, 45 | default=oura_const.DEFAULT_MONITORED_DATES 46 | ): cv.ensure_list, 47 | 48 | vol.Optional( 49 | const.CONF_MONITORED_VARIABLES, 50 | default=_DEFAULT_MONITORED_VARIABLES 51 | ): vol.All(cv.ensure_list, [vol.In(_SUPPORTED_MONITORED_VARIABLES)]), 52 | 53 | vol.Optional( 54 | oura_const.CONF_BACKFILL, 55 | default=oura_const.DEFAULT_BACKFILL 56 | ): cv.positive_int, 57 | } 58 | 59 | _EMPTY_SENSOR_ATTRIBUTE = { 60 | variable: None for variable in _SUPPORTED_MONITORED_VARIABLES 61 | } 62 | 63 | 64 | class OuraSleepScoreSensor(sensor_base_dated.OuraDatedSensor): 65 | """Representation of an Oura Ring Sleep Score sensor. 66 | 67 | Attributes: 68 | name: name of the sensor. 69 | state: state of the sensor. 70 | extra_state_attributes: attributes of the sensor. 71 | 72 | Methods: 73 | async_update: updates sensor data. 74 | """ 75 | 76 | def __init__(self, config, hass): 77 | """Initializes the sensor.""" 78 | sleep_score_config = ( 79 | config.get(const.CONF_SENSORS, {}).get(CONF_KEY_NAME, {})) 80 | super(OuraSleepScoreSensor, self).__init__( 81 | config, hass, sleep_score_config) 82 | 83 | self._api_endpoint = api.OuraEndpoints.SLEEP_SCORE 84 | self._empty_sensor = _EMPTY_SENSOR_ATTRIBUTE 85 | 86 | def parse_individual_datapoint(self, datapoint): 87 | """Parses the individual day or datapoint. 88 | 89 | Args: 90 | datapoint: Object for an individual day or datapoint. 91 | 92 | Returns: 93 | Modified datapoint with right parsed data. 94 | """ 95 | datapoint_copy = {} 96 | datapoint_copy.update(datapoint) 97 | 98 | contributors = datapoint_copy.get('contributors', {}) 99 | datapoint_copy.update(contributors) 100 | del datapoint_copy['contributors'] 101 | 102 | return datapoint_copy 103 | -------------------------------------------------------------------------------- /custom_components/oura/sensor_workouts.py: -------------------------------------------------------------------------------- 1 | """Provides a workouts sensor.""" 2 | 3 | import voluptuous as vol 4 | from homeassistant import const 5 | from homeassistant.helpers import config_validation as cv 6 | from . import api 7 | from . import const as oura_const 8 | from . import sensor_base_dated_series 9 | 10 | # Sensor configuration 11 | CONF_KEY_NAME = 'workouts' 12 | 13 | _DEFAULT_NAME = 'oura_workouts' 14 | 15 | _DEFAULT_ATTRIBUTE_STATE = 'activity' 16 | 17 | _DEFAULT_MONITORED_VARIABLES = [ 18 | 'activity', 19 | 'calories', 20 | 'day', 21 | 'intensity', 22 | ] 23 | 24 | _SUPPORTED_MONITORED_VARIABLES = [ 25 | 'activity', 26 | 'calories', 27 | 'day', 28 | 'distance', 29 | 'end_datetime', 30 | 'intensity', 31 | 'label', 32 | 'source', 33 | 'start_datetime', 34 | ] 35 | 36 | CONF_SCHEMA = { 37 | vol.Optional(const.CONF_NAME, default=_DEFAULT_NAME): cv.string, 38 | 39 | vol.Optional( 40 | oura_const.CONF_ATTRIBUTE_STATE, 41 | default=_DEFAULT_ATTRIBUTE_STATE 42 | ): vol.In(_SUPPORTED_MONITORED_VARIABLES), 43 | 44 | vol.Optional( 45 | oura_const.CONF_MONITORED_DATES, 46 | default=oura_const.DEFAULT_MONITORED_DATES 47 | ): cv.ensure_list, 48 | 49 | vol.Optional( 50 | const.CONF_MONITORED_VARIABLES, 51 | default=_DEFAULT_MONITORED_VARIABLES 52 | ): vol.All(cv.ensure_list, [vol.In(_SUPPORTED_MONITORED_VARIABLES)]), 53 | 54 | vol.Optional( 55 | oura_const.CONF_BACKFILL, 56 | default=oura_const.DEFAULT_BACKFILL 57 | ): cv.positive_int, 58 | } 59 | 60 | _EMPTY_SENSOR_ATTRIBUTE = { 61 | variable: None for variable in _SUPPORTED_MONITORED_VARIABLES 62 | } 63 | 64 | 65 | class OuraWorkoutsSensor(sensor_base_dated_series.OuraDatedSeriesSensor): 66 | """Representation of an Oura Ring Workouts sensor. 67 | 68 | Attributes: 69 | name: name of the sensor. 70 | state: state of the sensor. 71 | extra_state_attributes: attributes of the sensor. 72 | 73 | Methods: 74 | async_update: updates sensor data. 75 | """ 76 | 77 | def __init__(self, config, hass): 78 | """Initializes the sensor.""" 79 | sessions_config = ( 80 | config.get(const.CONF_SENSORS, {}).get(CONF_KEY_NAME, {})) 81 | super(OuraWorkoutsSensor, self).__init__(config, hass, sessions_config) 82 | 83 | self._api_endpoint = api.OuraEndpoints.WORKOUTS 84 | self._empty_sensor = _EMPTY_SENSOR_ATTRIBUTE 85 | -------------------------------------------------------------------------------- /docs/img/apex-charts-scores.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nitobuendia/oura-custom-component/4ddacb388d8287324d771a7f1928ff81e614d283/docs/img/apex-charts-scores.png -------------------------------------------------------------------------------- /docs/img/apex-charts-sleep-trend.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nitobuendia/oura-custom-component/4ddacb388d8287324d771a7f1928ff81e614d283/docs/img/apex-charts-sleep-trend.png --------------------------------------------------------------------------------