├── .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 |
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 | 
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 | 
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
--------------------------------------------------------------------------------