Skip to content

Commit 05af23d

Browse files
novalegraps2
andauthored
Incorporate sleep data into complication user info transfer calculations (#1217)
* Actually resolve them :-) * Add what I have * Add sleep permission * Refine complication math * Improvements to complication-refresh code * Update to match dev * Make cartfile accurate * Add newline * TimeInterval -> Date * Ensure last update time is updated in case of failure * Remove print statement * Changes based on review * More changes in response to review * Avoid crash on HKSampleQuery error * Fix crash due to incorrect error type * Fix for authorization error * Remove delay to mirror LoopKit * Update ExponentialInsulinModelPreset.swift Co-authored-by: Pete Schwamb <[email protected]>
1 parent b6c1573 commit 05af23d

File tree

6 files changed

+250
-17
lines changed

6 files changed

+250
-17
lines changed

Loop.xcodeproj/project.pbxproj

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -389,6 +389,7 @@
389389
C1FB428D21791D2500FAB378 /* PumpManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43C3B6F620BBCAA30026CAFA /* PumpManager.swift */; };
390390
C1FB428F217921D600FAB378 /* PumpManagerUI.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1FB428E217921D600FAB378 /* PumpManagerUI.swift */; };
391391
C1FB4290217922A100FAB378 /* PumpManagerUI.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1FB428E217921D600FAB378 /* PumpManagerUI.swift */; };
392+
E9BB27AB23B85C3500FB4987 /* SleepStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9BB27AA23B85C3500FB4987 /* SleepStore.swift */; };
392393
/* End PBXBuildFile section */
393394

394395
/* Begin PBXContainerItemProxy section */
@@ -1097,6 +1098,7 @@
10971098
C1F8B1DB223862D500DD66CF /* BolusProgressTableViewCell.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = BolusProgressTableViewCell.xib; sourceTree = "<group>"; };
10981099
C1FB428B217806A300FAB378 /* StateColorPalette.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = StateColorPalette.swift; sourceTree = "<group>"; };
10991100
C1FB428E217921D600FAB378 /* PumpManagerUI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PumpManagerUI.swift; sourceTree = "<group>"; };
1101+
E9BB27AA23B85C3500FB4987 /* SleepStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SleepStore.swift; sourceTree = "<group>"; };
11001102
/* End PBXFileReference section */
11011103

11021104
/* Begin PBXFrameworksBuildPhase section */
@@ -1649,6 +1651,7 @@
16491651
4F70C20F1DE8FAC5006380B7 /* StatusExtensionDataManager.swift */,
16501652
89ADE13A226BFA0F0067222B /* TestingScenariosManager.swift */,
16511653
4328E0341CFC0AE100E199AA /* WatchDataManager.swift */,
1654+
E9BB27AA23B85C3500FB4987 /* SleepStore.swift */,
16521655
);
16531656
path = Managers;
16541657
sourceTree = "<group>";
@@ -2626,6 +2629,7 @@
26262629
430B29932041F5B300BA9F93 /* UserDefaults+Loop.swift in Sources */,
26272630
4341F4EB1EDB92AC001C936B /* LogglyService.swift in Sources */,
26282631
43CE7CDE1CA8B63E003CC1B0 /* Data.swift in Sources */,
2632+
E9BB27AB23B85C3500FB4987 /* SleepStore.swift in Sources */,
26292633
C1F8B243223E73FD00DD66CF /* BolusProgressTableViewCell.swift in Sources */,
26302634
89D6953E23B6DF8A002B3066 /* PotentialCarbEntryTableViewCell.swift in Sources */,
26312635
89CA2B30226C0161004D9350 /* DirectoryObserver.swift in Sources */,

Loop/Base.lproj/InfoPlist.strings

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
"NSFaceIDUsageDescription" = "Face ID is used to authenticate insulin bolus.";
1212

1313
/* Privacy - Health Share Usage Description */
14-
"NSHealthShareUsageDescription" = "Meal data from the Health database is used to determine glucose effects. Glucose data from the Health database is used for graphing and momentum calculation.";
14+
"NSHealthShareUsageDescription" = "Meal data from the Health database is used to determine glucose effects. Glucose data from the Health database is used for graphing and momentum calculation. Sleep data from the Health database is used to improve the Apple Watch complication.";
1515

1616
/* Privacy - Health Update Usage Description */
1717
"NSHealthUpdateUsageDescription" = "Carbohydrate meal data entered in the app and on the watch is stored in the Health database. Glucose data retrieved from the CGM is stored securely in HealthKit.";

Loop/Managers/LoopDataManager.swift

Lines changed: 25 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -440,32 +440,52 @@ extension LoopDataManager {
440440
}
441441
}
442442

443-
/// All the HealthKit types to be read and shared by stores
444-
private var sampleTypes: Set<HKSampleType> {
443+
/// All the HealthKit types to be read by stores
444+
private var readTypes: Set<HKSampleType> {
445445
return Set([
446446
glucoseStore.sampleType,
447447
carbStore.sampleType,
448448
doseStore.sampleType,
449+
HKObjectType.categoryType(forIdentifier: HKCategoryTypeIdentifier.sleepAnalysis)!
449450
].compactMap { $0 })
450451
}
452+
453+
/// All the HealthKit types to be shared by stores
454+
private var shareTypes: Set<HKSampleType> {
455+
return Set([
456+
glucoseStore.sampleType,
457+
carbStore.sampleType,
458+
doseStore.sampleType,
459+
].compactMap { $0 })
460+
}
461+
462+
var sleepDataAuthorizationRequired: Bool {
463+
return carbStore.healthStore.authorizationStatus(for: HKObjectType.categoryType(forIdentifier: HKCategoryTypeIdentifier.sleepAnalysis)!) == .notDetermined
464+
}
465+
466+
var sleepDataSharingDenied: Bool {
467+
return carbStore.healthStore.authorizationStatus(for: HKObjectType.categoryType(forIdentifier: HKCategoryTypeIdentifier.sleepAnalysis)!) == .sharingDenied
468+
}
451469

452470
/// True if any stores require HealthKit authorization
453471
var authorizationRequired: Bool {
454472
return glucoseStore.authorizationRequired ||
455473
carbStore.authorizationRequired ||
456-
doseStore.authorizationRequired
474+
doseStore.authorizationRequired ||
475+
sleepDataAuthorizationRequired
457476
}
458477

459478
/// True if the user has explicitly denied access to any stores' HealthKit types
460479
private var sharingDenied: Bool {
461480
return glucoseStore.sharingDenied ||
462481
carbStore.sharingDenied ||
463-
doseStore.sharingDenied
482+
doseStore.sharingDenied ||
483+
sleepDataSharingDenied
464484
}
465485

466486
func authorize(_ completion: @escaping () -> Void) {
467487
// Authorize all types at once for simplicity
468-
carbStore.healthStore.requestAuthorization(toShare: sampleTypes, read: sampleTypes) { (success, error) in
488+
carbStore.healthStore.requestAuthorization(toShare: shareTypes, read: readTypes) { (success, error) in
469489
if success {
470490
// Call the individual authorization methods to trigger query creation
471491
self.carbStore.authorize({ _ in })

Loop/Managers/SleepStore.swift

Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
//
2+
// SleepStore.swift
3+
// Loop
4+
//
5+
// Created by Anna Quinlan on 12/28/19.
6+
// Copyright © 2019 LoopKit Authors. All rights reserved.
7+
//
8+
9+
import Foundation
10+
import HealthKit
11+
import os.log
12+
13+
enum SleepStoreResult<T> {
14+
case success(T)
15+
case failure(SleepStoreError)
16+
}
17+
18+
enum SleepStoreError: Error {
19+
case noMatchingBedtime
20+
case unknownReturnConfiguration
21+
case noSleepDataAvailable
22+
case queryError(String) // String is description of error
23+
}
24+
25+
class SleepStore {
26+
var healthStore: HKHealthStore
27+
28+
private let log = OSLog(category: "SleepStore")
29+
30+
public init(
31+
healthStore: HKHealthStore
32+
) {
33+
self.healthStore = healthStore
34+
}
35+
36+
func getAverageSleepStartTime(sampleLimit: Int = 30, _ completion: @escaping (_ result: SleepStoreResult<Date>) -> Void) {
37+
let inBedPredicate = HKQuery.predicateForCategorySamples(
38+
with: .equalTo,
39+
value: HKCategoryValueSleepAnalysis.inBed.rawValue
40+
)
41+
42+
let asleepPredicate = HKQuery.predicateForCategorySamples(
43+
with: .equalTo,
44+
value: HKCategoryValueSleepAnalysis.asleep.rawValue
45+
)
46+
47+
getAverageSleepStartTime(matching: inBedPredicate, sampleLimit: sampleLimit) {
48+
(result) in
49+
switch result {
50+
case .success(_):
51+
completion(result)
52+
case .failure(let error):
53+
switch error {
54+
case SleepStoreError.noSleepDataAvailable:
55+
// if there were no .inBed samples, check if there are any .asleep samples that could be used to estimate bedtime
56+
self.getAverageSleepStartTime(matching: asleepPredicate, sampleLimit: sampleLimit, completion)
57+
default:
58+
// otherwise, call completion
59+
completion(result)
60+
}
61+
}
62+
63+
}
64+
}
65+
66+
fileprivate func getAverageSleepStartTime(matching predicate: NSPredicate, sampleLimit: Int, _ completion: @escaping (_ result: SleepStoreResult<Date>) -> Void) {
67+
let sleepType = HKObjectType.categoryType(forIdentifier: HKCategoryTypeIdentifier.sleepAnalysis)!
68+
69+
// get more-recent values first
70+
let sortByDate = NSSortDescriptor(key: HKSampleSortIdentifierStartDate, ascending: false)
71+
72+
let query = HKSampleQuery(sampleType: sleepType, predicate: predicate, limit: sampleLimit, sortDescriptors: [sortByDate]) { (query, samples, error) in
73+
74+
if let error = error {
75+
self.log.error("Error fetching sleep data: %{public}@", String(describing: error))
76+
completion(.failure(SleepStoreError.queryError(error.localizedDescription)))
77+
} else if let samples = samples as? [HKCategorySample] {
78+
guard !samples.isEmpty else {
79+
completion(.failure(SleepStoreError.noSleepDataAvailable))
80+
return
81+
}
82+
83+
// find the average hour and minute components from the sleep start times
84+
let average = samples.reduce(0, {
85+
if let metadata = $1.metadata, let timezone = metadata[HKMetadataKeyTimeZone] {
86+
return $0 + $1.startDate.timeOfDayInSeconds(sampleTimeZone: NSTimeZone(name: timezone as! String)! as TimeZone)
87+
} else {
88+
// default to the current timezone if the sample does not contain one in its metadata
89+
return $0 + $1.startDate.timeOfDayInSeconds(sampleTimeZone: Calendar.current.timeZone)
90+
}
91+
}) / samples.count
92+
93+
let averageHour = average / 3600
94+
let averageMinute = average % 3600 / 60
95+
96+
// find the next time that the user will go to bed, based on the averages we've computed
97+
if let bedtime = Calendar.current.nextDate(after: Date(), matching: DateComponents(hour: averageHour, minute: averageMinute), matchingPolicy: .nextTime), bedtime.timeIntervalSinceNow <= .hours(24) {
98+
completion(.success(bedtime))
99+
} else {
100+
completion(.failure(SleepStoreError.noMatchingBedtime))
101+
}
102+
} else {
103+
completion(.failure(SleepStoreError.unknownReturnConfiguration))
104+
}
105+
}
106+
healthStore.execute(query)
107+
}
108+
}
109+
110+
extension Date {
111+
fileprivate func timeOfDayInSeconds(sampleTimeZone: TimeZone) -> Int {
112+
var calendar = Calendar.current
113+
calendar.timeZone = sampleTimeZone
114+
115+
let dateComponents = calendar.dateComponents([.hour, .minute, .second], from: self)
116+
let dateSeconds = dateComponents.hour! * 3600 + dateComponents.minute! * 60 + dateComponents.second!
117+
118+
return dateSeconds
119+
}
120+
}

Loop/Managers/WatchDataManager.swift

Lines changed: 72 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -12,13 +12,15 @@ import WatchConnectivity
1212
import LoopKit
1313
import LoopCore
1414

15-
1615
final class WatchDataManager: NSObject {
1716

1817
unowned let deviceManager: DeviceDataManager
1918

2019
init(deviceManager: DeviceDataManager) {
2120
self.deviceManager = deviceManager
21+
self.sleepStore = SleepStore (healthStore: deviceManager.loopManager.glucoseStore.healthStore)
22+
self.lastBedtimeQuery = UserDefaults.appGroup?.lastBedtimeQuery ?? .distantPast
23+
self.bedtime = UserDefaults.appGroup?.bedtime
2224
self.log = DiagnosticLogger.shared.forCategory("WatchDataManager")
2325

2426
super.init()
@@ -41,6 +43,53 @@ final class WatchDataManager: NSObject {
4143

4244
private var lastSentSettings: LoopSettings?
4345

46+
let sleepStore: SleepStore
47+
48+
var lastBedtimeQuery: Date {
49+
didSet {
50+
UserDefaults.appGroup?.lastBedtimeQuery = lastBedtimeQuery
51+
}
52+
}
53+
54+
var bedtime: Date? {
55+
didSet {
56+
UserDefaults.appGroup?.bedtime = bedtime
57+
}
58+
}
59+
60+
private func updateBedtimeIfNeeded() {
61+
let now = Date()
62+
let lastUpdateInterval = now.timeIntervalSince(lastBedtimeQuery)
63+
let calendar = Calendar.current
64+
65+
guard lastUpdateInterval >= TimeInterval(hours: 24) else {
66+
// increment the bedtime by 1 day if it's before the current time, but we don't need to make another HealthKit query yet
67+
if let bedtime = bedtime, bedtime < now {
68+
let hourComponent = calendar.component(.hour, from: bedtime)
69+
let minuteComponent = calendar.component(.minute, from: bedtime)
70+
71+
if let newBedtime = calendar.nextDate(after: now, matching: DateComponents(hour: hourComponent, minute: minuteComponent), matchingPolicy: .nextTime), newBedtime.timeIntervalSinceNow <= .hours(24) {
72+
self.bedtime = newBedtime
73+
}
74+
}
75+
76+
return
77+
}
78+
79+
sleepStore.getAverageSleepStartTime() {
80+
(result) in
81+
82+
self.lastBedtimeQuery = now
83+
84+
switch result {
85+
case .success(let bedtime):
86+
self.bedtime = bedtime
87+
case .failure:
88+
self.bedtime = nil
89+
}
90+
}
91+
}
92+
4493
@objc private func updateWatch(_ notification: Notification) {
4594
guard
4695
let rawUpdateContext = notification.userInfo?[LoopDataManager.LoopUpdateContextKey] as? LoopDataManager.LoopUpdateContext.RawValue,
@@ -113,12 +162,13 @@ final class WatchDataManager: NSObject {
113162
}
114163

115164
let complicationShouldUpdate: Bool
165+
updateBedtimeIfNeeded()
116166

117167
if let lastContext = lastComplicationContext,
118168
let lastGlucose = lastContext.glucose, let lastGlucoseDate = lastContext.glucoseDate,
119169
let newGlucose = context.glucose, let newGlucoseDate = context.glucoseDate
120170
{
121-
let enoughTimePassed = newGlucoseDate.timeIntervalSince(lastGlucoseDate) >= session.complicationUserInfoTransferInterval
171+
let enoughTimePassed = newGlucoseDate.timeIntervalSince(lastGlucoseDate) >= session.complicationUserInfoTransferInterval(bedtime: bedtime)
122172
let enoughTrendDrift = abs(newGlucose.doubleValue(for: minTrendUnit) - lastGlucose.doubleValue(for: minTrendUnit)) >= minTrendDrift
123173

124174
complicationShouldUpdate = enoughTimePassed || enoughTrendDrift
@@ -322,6 +372,9 @@ extension WatchDataManager {
322372
"## WatchDataManager",
323373
"lastSentSettings: \(String(describing: lastSentSettings))",
324374
"lastComplicationContext: \(String(describing: lastComplicationContext))",
375+
"lastBedtimeQuery: \(String(describing: lastBedtimeQuery))",
376+
"bedtime: \(String(describing: bedtime))",
377+
"complicationUserInfoTransferInterval: \(round(watchSession?.complicationUserInfoTransferInterval(bedtime: bedtime).minutes ?? 0)) min"
325378
]
326379

327380
if let session = watchSession {
@@ -334,8 +387,8 @@ extension WatchDataManager {
334387

335388
return items.joined(separator: "\n")
336389
}
337-
}
338390

391+
}
339392

340393
extension WCSession {
341394
open override var debugDescription: String {
@@ -350,21 +403,29 @@ extension WCSession {
350403
"* outstandingUserInfoTransfers: \(outstandingUserInfoTransfers)",
351404
"* receivedApplicationContext: \(receivedApplicationContext)",
352405
"* remainingComplicationUserInfoTransfers: \(remainingComplicationUserInfoTransfers)",
353-
"* complicationUserInfoTransferInterval: \(round(complicationUserInfoTransferInterval.minutes)) min",
354406
"* watchDirectoryURL: \(watchDirectoryURL?.absoluteString ?? "nil")",
355407
].joined(separator: "\n")
356408
}
357-
358-
fileprivate var complicationUserInfoTransferInterval: TimeInterval {
409+
410+
fileprivate func complicationUserInfoTransferInterval(bedtime: Date?) -> TimeInterval {
359411
let now = Date()
360-
let timeUntilMidnight: TimeInterval
412+
let timeUntilRefresh: TimeInterval
361413

362414
if let midnight = Calendar.current.nextDate(after: now, matching: DateComponents(hour: 0), matchingPolicy: .nextTime) {
363-
timeUntilMidnight = midnight.timeIntervalSince(now)
415+
// we can have a more frequent refresh rate if we only refresh when it's likely the user is awake (based on HealthKit sleep data)
416+
if let nextBedtime = bedtime {
417+
let timeUntilBedtime = nextBedtime.timeIntervalSince(now)
418+
// if bedtime is before the current time or more than 24 hours away, use midnight instead
419+
timeUntilRefresh = (0..<TimeInterval(hours: 24)).contains(timeUntilBedtime) ? timeUntilBedtime : midnight.timeIntervalSince(now)
420+
}
421+
// otherwise, since (in most cases) the complications allowance refreshes at midnight, base it on the time remaining until midnight
422+
else {
423+
timeUntilRefresh = midnight.timeIntervalSince(now)
424+
}
364425
} else {
365-
timeUntilMidnight = .hours(24)
426+
timeUntilRefresh = .hours(24)
366427
}
367-
368-
return timeUntilMidnight / Double(remainingComplicationUserInfoTransfers + 1)
428+
429+
return timeUntilRefresh / Double(remainingComplicationUserInfoTransfers + 1)
369430
}
370431
}

0 commit comments

Comments
 (0)