-
Notifications
You must be signed in to change notification settings - Fork 1.3k
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Incorporate sleep data into complication user info transfer calculations #1217
Changes from 1 commit
774a272
6849b3c
ab440e9
c1512d4
ee2227a
0658c47
f68e6ef
e503af4
2e742d7
53c98a8
a65f586
cc17049
28795e5
ec14d2e
e70a89e
fbaa204
f83217a
20bc003
f71c485
aeb14be
ffbaea9
975c296
cc0d636
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
- Loading branch information
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,121 @@ | ||
// | ||
// SleepStore.swift | ||
// Loop | ||
// | ||
// Created by Anna Quinlan on 12/28/19. | ||
// Copyright © 2019 LoopKit Authors. All rights reserved. | ||
// | ||
|
||
import Foundation | ||
import HealthKit | ||
|
||
enum SleepStoreResult<T> { | ||
case success(T) | ||
case failure(Error) | ||
} | ||
|
||
enum SleepStoreError: Error { | ||
case noMatchingBedtime | ||
case unknownReturnConfiguration | ||
case noSleepDataAvailable | ||
} | ||
|
||
extension SleepStoreError: LocalizedError { | ||
public var localizedDescription: String { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. It doesn't look like we'll be surfacing these errors to the user, so you can remove the localized descriptions. |
||
switch self { | ||
case .noMatchingBedtime: | ||
return NSLocalizedString("Could not find a matching bedtime", comment: "") | ||
case .unknownReturnConfiguration: | ||
return NSLocalizedString("Unknown return configuration from query", comment: "") | ||
case .noSleepDataAvailable: | ||
return NSLocalizedString("No sleep data available", comment: "") | ||
} | ||
} | ||
} | ||
|
||
class SleepStore { | ||
var healthStore: HKHealthStore | ||
var sampleLimit: Int | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
|
||
public init( | ||
healthStore: HKHealthStore, | ||
sampleLimit: Int = 30 | ||
) { | ||
self.healthStore = healthStore | ||
self.sampleLimit = sampleLimit | ||
} | ||
|
||
func getAverageSleepStartTime(_ completion: @escaping (_ result: SleepStoreResult<Date>) -> Void) { | ||
let inBedPredicate = HKQuery.predicateForCategorySamples( | ||
with: .equalTo, | ||
value: HKCategoryValueSleepAnalysis.inBed.rawValue | ||
) | ||
|
||
let asleepPredicate = HKQuery.predicateForCategorySamples( | ||
with: .equalTo, | ||
value: HKCategoryValueSleepAnalysis.asleep.rawValue | ||
) | ||
|
||
getAverageSleepStartTime(matching: inBedPredicate, sampleLimit: sampleLimit) { | ||
(result) in | ||
switch result { | ||
case .success(_): | ||
completion(result) | ||
case .failure(let error): | ||
switch error { | ||
case SleepStoreError.noSleepDataAvailable: | ||
// if there were no .inBed samples, check if there are any .asleep samples that could be used to estimate bedtime | ||
self.getAverageSleepStartTime(matching: asleepPredicate, sampleLimit: self.sampleLimit, completion) | ||
default: | ||
// otherwise, call completion | ||
completion(result) | ||
} | ||
} | ||
|
||
} | ||
} | ||
|
||
fileprivate func getAverageSleepStartTime(matching predicate: NSPredicate, sampleLimit: Int, _ completion: @escaping (_ result: SleepStoreResult<Date>) -> Void) { | ||
let sleepType = HKObjectType.categoryType(forIdentifier: HKCategoryTypeIdentifier.sleepAnalysis)! | ||
|
||
// get more-recent values first | ||
let sortByDate = NSSortDescriptor(key: HKSampleSortIdentifierStartDate, ascending: false) | ||
|
||
let query = HKSampleQuery(sampleType: sleepType, predicate: predicate, limit: sampleLimit, sortDescriptors: [sortByDate]) { (query, samples, error) in | ||
|
||
if let error = error { | ||
completion(.failure(error)) | ||
} else if let samples = samples as? [HKCategorySample] { | ||
guard !samples.isEmpty else { | ||
completion(.failure(SleepStoreError.noSleepDataAvailable)) | ||
return | ||
} | ||
|
||
// find the average hour and minute components from the sleep start times | ||
let average = samples.reduce(0, {$0 + $1.startDate.timeOfDayInSeconds()}) / samples.count | ||
let averageHour = average / 3600 | ||
let averageMinute = average % 3600 / 60 | ||
|
||
// find the next time that the user will go to bed, based on the averages we've computed | ||
if let bedtime = Calendar.current.nextDate(after: Date(), matching: DateComponents(hour: averageHour, minute: averageMinute), matchingPolicy: .nextTime), bedtime.timeIntervalSinceNow <= .hours(24) { | ||
completion(.success(bedtime)) | ||
} else { | ||
completion(.failure(SleepStoreError.noMatchingBedtime)) | ||
} | ||
} else { | ||
completion(.failure(SleepStoreError.unknownReturnConfiguration)) | ||
} | ||
} | ||
healthStore.execute(query) | ||
} | ||
} | ||
|
||
extension Date { | ||
fileprivate func timeOfDayInSeconds() -> Int { | ||
let calendar = Calendar.current | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. One detail here I forgot to mention—the time of day for a particular sleep sample is dependent upon the time zone in which the sample was recorded. For example, if a user went on vacation recently, using their home time zone to determine the time of day in seconds could yield a result that doesn't accurately reflect when they went to sleep in their vacation location's time zone. Add a parameter to this function of type TimeZone, and assign it to the There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. For unwrapping the timezone, what could an appropriate default be? We could use the current time zone, or exclude the sample from the analysis (though this would probably mean it wouldn't be possible to use a reduce function to average the samples) There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I just checked my sleep data, from SleepTracker, and it has timezone. Whether or not this field is present will depend on the app, and since it's metadata, the app isn't forced to store it. I think assuming local timezone in that case is probably the best option, as the user probably will have no samples with timezone. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Sounds good. Just checked my sleep data (some from Bedtime, some from SleepCycle) and it all contains timezones |
||
let dateComponents = calendar.dateComponents([.hour, .minute, .second], from: self) | ||
let dateSeconds = dateComponents.hour! * 3600 + dateComponents.minute! * 60 + dateComponents.second! | ||
|
||
return dateSeconds | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -12,17 +12,16 @@ import WatchConnectivity | |
import LoopKit | ||
import LoopCore | ||
|
||
enum SleepStoreResult<T> { | ||
case success(T) | ||
case failure(Error) | ||
} | ||
|
||
final class WatchDataManager: NSObject { | ||
|
||
unowned let deviceManager: DeviceDataManager | ||
|
||
init(deviceManager: DeviceDataManager) { | ||
self.deviceManager = deviceManager | ||
self.healthStore = deviceManager.loopManager.glucoseStore.healthStore | ||
self.sleepStore = SleepStore (healthStore: healthStore) | ||
self.lastBedtimeQuery = UserDefaults.appGroup?.lastBedtimeQuery ?? .distantPast | ||
self.bedtime = UserDefaults.appGroup?.bedtime | ||
self.log = DiagnosticLogger.shared.forCategory("WatchDataManager") | ||
|
||
super.init() | ||
|
@@ -45,35 +44,50 @@ final class WatchDataManager: NSObject { | |
|
||
private var lastSentSettings: LoopSettings? | ||
|
||
let healthStore = HKHealthStore() | ||
private var lastBedtimeUpdate: Date = Calendar.current.date(byAdding: .hour, value: -25, to: Date())! | ||
private var bedtime: Date? | ||
let healthStore: HKHealthStore | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I believe we no longer need a HKHealthStore instance directly inside WatchDataManager, since the access is managed through SleepStore; is this property used anywhere? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Nope, removed |
||
let sleepStore: SleepStore | ||
|
||
private func updateBedtime() { | ||
let lastUpdateInterval = Date().timeIntervalSince(lastBedtimeUpdate) | ||
guard | ||
lastUpdateInterval < TimeInterval(hours: 24) | ||
else { | ||
// only look at samples within the past 6 months | ||
let monthsToGoBack = -6 | ||
let start = Calendar.current.date(byAdding: .month, value: monthsToGoBack, to: Date())! | ||
|
||
getSleepStartTime(start: start) { | ||
(result) in | ||
|
||
// update when we last checked the bedtime | ||
self.lastBedtimeUpdate = Date() | ||
var lastBedtimeQuery: Date { | ||
didSet { | ||
UserDefaults.appGroup?.lastBedtimeQuery = lastBedtimeQuery | ||
} | ||
} | ||
|
||
var bedtime: Date? { | ||
didSet { | ||
UserDefaults.appGroup?.bedtime = bedtime | ||
} | ||
} | ||
|
||
private func updateBedtimeIfNeeded() { | ||
let now = Date() | ||
let lastUpdateInterval = now.timeIntervalSince(lastBedtimeQuery) | ||
let calendar = Calendar.current | ||
|
||
guard lastUpdateInterval >= TimeInterval(hours: 24) else { | ||
// increment the bedtime by 1 day if it's before the current time, but we don't need to make another HealthKit query yet | ||
if let bedtime = bedtime, bedtime < now { | ||
let hourComponent = calendar.component(.hour, from: bedtime) | ||
let minuteComponent = calendar.component(.minute, from: bedtime) | ||
|
||
switch result { | ||
case .success(let bedtime): | ||
self.bedtime = bedtime | ||
case .failure: | ||
return | ||
if let newBedtime = calendar.nextDate(after: now, matching: DateComponents(hour: hourComponent, minute: minuteComponent), matchingPolicy: .nextTime) { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Here's another case where |
||
self.bedtime = newBedtime | ||
} | ||
} | ||
|
||
return | ||
} | ||
|
||
sleepStore.getAverageSleepStartTime() { | ||
(result) in | ||
self.lastBedtimeQuery = now | ||
switch result { | ||
case .success(let bedtime): | ||
self.bedtime = bedtime | ||
case .failure: | ||
self.bedtime = nil | ||
} | ||
} | ||
} | ||
|
||
@objc private func updateWatch(_ notification: Notification) { | ||
|
@@ -148,7 +162,7 @@ final class WatchDataManager: NSObject { | |
} | ||
|
||
let complicationShouldUpdate: Bool | ||
updateBedtime() | ||
updateBedtimeIfNeeded() | ||
|
||
if let lastContext = lastComplicationContext, | ||
let lastGlucose = lastContext.glucose, let lastGlucoseDate = lastContext.glucoseDate, | ||
|
@@ -358,6 +372,7 @@ extension WatchDataManager { | |
"## WatchDataManager", | ||
"lastSentSettings: \(String(describing: lastSentSettings))", | ||
"lastComplicationContext: \(String(describing: lastComplicationContext))", | ||
"lastBedtimeQuery: \(String(describing: lastBedtimeQuery))", | ||
"bedtime: \(String(describing: bedtime))", | ||
"complicationUserInfoTransferInterval: \(round(watchSession?.complicationUserInfoTransferInterval(bedtime: bedtime).minutes ?? 0)) min" | ||
] | ||
|
@@ -372,55 +387,9 @@ extension WatchDataManager { | |
|
||
return items.joined(separator: "\n") | ||
} | ||
|
||
private func getSleepStartTime(start: Date, end: Date? = nil, sampleLimit: Int = 30, _ completion: @escaping (_ result: SleepStoreResult<Date>) -> Void) { | ||
let predicate = HKQuery.predicateForSamples(withStart: start, end: end, options: []) | ||
|
||
getSleepStartTime(matching: predicate, sampleLimit: sampleLimit, completion) | ||
} | ||
|
||
private func getSleepStartTime(matching predicate: NSPredicate, sampleLimit: Int, _ completion: @escaping (_ result: SleepStoreResult<Date>) -> Void) { | ||
let sleepType = HKObjectType.categoryType(forIdentifier: HKCategoryTypeIdentifier.sleepAnalysis)! | ||
// get more-recent values first | ||
let sortDescriptor = NSSortDescriptor(key: HKSampleSortIdentifierEndDate, ascending: false) | ||
|
||
let query = HKSampleQuery(sampleType: sleepType, predicate: predicate, limit: sampleLimit, sortDescriptors: [sortDescriptor]) { (query, samples, error) in | ||
|
||
if let error = error { | ||
completion(.failure(error)) | ||
} else if let samples = samples as? [HKCategorySample] { | ||
// find the average hour and minute components from the sleep start times | ||
let average = samples.reduce(0, {$0 + $1.startDate.secondsFromMidnight()}) / samples.count | ||
let averageHour = average / 3600 | ||
let averageMinute = average % 3600 / 60 | ||
|
||
// find the next time that the user will go to bed, based on the averages we've computed | ||
if let time = Calendar.current.nextDate(after: Date(), matching: DateComponents(hour: averageHour, minute: averageMinute), matchingPolicy: .nextTime) { | ||
completion(.success(time)) | ||
} else { | ||
completion(.failure(NSError())) | ||
} | ||
} else { | ||
assertionFailure("Unknown return configuration from query \(query)") | ||
} | ||
} | ||
|
||
healthStore.execute(query) | ||
} | ||
|
||
} | ||
|
||
extension Date { | ||
fileprivate func secondsFromMidnight() -> Int { | ||
let calendar = Calendar.current | ||
let dateComponents = calendar.dateComponents([.hour, .minute, .second], from: self) | ||
let dateSeconds = dateComponents.hour! * 3600 + dateComponents.minute! * 60 + dateComponents.second! | ||
|
||
return dateSeconds | ||
} | ||
} | ||
|
||
|
||
extension WCSession { | ||
open override var debugDescription: String { | ||
return [ | ||
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
You can use SleepStoreError as the type contained by the
failure
case.