From 6849b3cd0878530aa04229990e6bc838b271052c Mon Sep 17 00:00:00 2001 From: AQ Date: Sun, 22 Dec 2019 14:05:40 -0800 Subject: [PATCH 01/18] Actually resolve them :-) --- Cartfile | 10 ---------- Cartfile.resolved | 10 ---------- DoseMathTests/Info.plist | 4 ---- Loop Status Extension/Info.plist | 4 ---- Loop/Info.plist | 4 ---- LoopTests/Info.plist | 4 ---- LoopUI/Info.plist | 4 ---- WatchApp Extension/Info.plist | 4 ---- WatchApp/Info.plist | 4 ---- 9 files changed, 48 deletions(-) diff --git a/Cartfile b/Cartfile index d2448d0f15..d96c224279 100644 --- a/Cartfile +++ b/Cartfile @@ -1,17 +1,7 @@ -<<<<<<< HEAD -github "LoopKit/LoopKit" "dev" -github "LoopKit/CGMBLEKit" "dev" -github "i-schuetz/SwiftCharts" == 0.6.5 -github "LoopKit/dexcom-share-client-swift" "dev" -github "LoopKit/G4ShareSpy" "dev" -github "ps2/rileylink_ios" "dev" -======= - github "LoopKit/LoopKit" ~> 2.2 github "LoopKit/CGMBLEKit" ~> 3.1 github "ps2/SwiftCharts" "hotfix-xcode10.2" github "LoopKit/dexcom-share-client-swift" == 1.0 github "LoopKit/G4ShareSpy" == 1.0 github "ps2/rileylink_ios" ~> 2.1 ->>>>>>> 5df0b4c1110b99947461e6b5047930b18582128d github "LoopKit/Amplitude-iOS" "decreepify" diff --git a/Cartfile.resolved b/Cartfile.resolved index 7e70489e17..eeb308dc0f 100644 --- a/Cartfile.resolved +++ b/Cartfile.resolved @@ -1,17 +1,7 @@ github "LoopKit/Amplitude-iOS" "2137d5fd44bf630ed33e1e72d7af6d8f8612f270" -<<<<<<< HEAD -github "LoopKit/CGMBLEKit" "fe92d93c24d18ff9755ff027e9b036d3769a8a0a" -github "LoopKit/G4ShareSpy" "f6ae0cf1753131135f26046ddf07e096da884b84" -github "LoopKit/LoopKit" "b47d60e6ed202ca30e4adc63c6d237ac100e72b4" -github "LoopKit/MKRingProgressView" "f548a5c64832be2d37d7c91b5800e284887a2a0a" -github "LoopKit/dexcom-share-client-swift" "cdbb1cd19cf54b37add6d6c25300445a6abe24da" -github "i-schuetz/SwiftCharts" "0.6.5" -github "ps2/rileylink_ios" "43789bd2d25e8ce32a0ccae31a4b85f419eb8aa9" -======= github "LoopKit/CGMBLEKit" "v3.1" github "LoopKit/G4ShareSpy" "v1.0" github "LoopKit/LoopKit" "v2.2.2" github "LoopKit/dexcom-share-client-swift" "v1.0" github "ps2/SwiftCharts" "cc8c401171d5ccb638ec6c87f6c410ee31fa774d" github "ps2/rileylink_ios" "v2.1.1" ->>>>>>> 5df0b4c1110b99947461e6b5047930b18582128d diff --git a/DoseMathTests/Info.plist b/DoseMathTests/Info.plist index 8cf70f6530..cf0ee080ee 100644 --- a/DoseMathTests/Info.plist +++ b/DoseMathTests/Info.plist @@ -15,11 +15,7 @@ CFBundlePackageType BNDL CFBundleShortVersionString -<<<<<<< HEAD 1.10.1 -======= - 1.9.6 ->>>>>>> 5df0b4c1110b99947461e6b5047930b18582128d CFBundleSignature ???? CFBundleVersion diff --git a/Loop Status Extension/Info.plist b/Loop Status Extension/Info.plist index 5e88ea1ba6..a829537cb1 100644 --- a/Loop Status Extension/Info.plist +++ b/Loop Status Extension/Info.plist @@ -19,11 +19,7 @@ CFBundlePackageType XPC! CFBundleShortVersionString -<<<<<<< HEAD - $(LOOP_MARKETING_VERSION) -======= 1.9.6 ->>>>>>> 5df0b4c1110b99947461e6b5047930b18582128d CFBundleVersion $(CURRENT_PROJECT_VERSION) MainAppBundleIdentifier diff --git a/Loop/Info.plist b/Loop/Info.plist index c0343ec1b5..18d41e3bee 100644 --- a/Loop/Info.plist +++ b/Loop/Info.plist @@ -19,11 +19,7 @@ CFBundlePackageType APPL CFBundleShortVersionString -<<<<<<< HEAD - $(LOOP_MARKETING_VERSION) -======= 1.9.6 ->>>>>>> 5df0b4c1110b99947461e6b5047930b18582128d CFBundleSignature ???? CFBundleURLTypes diff --git a/LoopTests/Info.plist b/LoopTests/Info.plist index 8cf70f6530..cf0ee080ee 100644 --- a/LoopTests/Info.plist +++ b/LoopTests/Info.plist @@ -15,11 +15,7 @@ CFBundlePackageType BNDL CFBundleShortVersionString -<<<<<<< HEAD 1.10.1 -======= - 1.9.6 ->>>>>>> 5df0b4c1110b99947461e6b5047930b18582128d CFBundleSignature ???? CFBundleVersion diff --git a/LoopUI/Info.plist b/LoopUI/Info.plist index ce8e014131..ff26182ff1 100644 --- a/LoopUI/Info.plist +++ b/LoopUI/Info.plist @@ -17,11 +17,7 @@ CFBundlePackageType FMWK CFBundleShortVersionString -<<<<<<< HEAD $(LOOP_MARKETING_VERSION) -======= - 1.9.6 ->>>>>>> 5df0b4c1110b99947461e6b5047930b18582128d CFBundleVersion $(CURRENT_PROJECT_VERSION) NSPrincipalClass diff --git a/WatchApp Extension/Info.plist b/WatchApp Extension/Info.plist index 6e1b7353d3..4ea1e4ca82 100644 --- a/WatchApp Extension/Info.plist +++ b/WatchApp Extension/Info.plist @@ -17,11 +17,7 @@ CFBundlePackageType XPC! CFBundleShortVersionString -<<<<<<< HEAD $(LOOP_MARKETING_VERSION) -======= - 1.9.6 ->>>>>>> 5df0b4c1110b99947461e6b5047930b18582128d CFBundleSignature ???? CFBundleVersion diff --git a/WatchApp/Info.plist b/WatchApp/Info.plist index 1c1d2e847d..70d55802d0 100644 --- a/WatchApp/Info.plist +++ b/WatchApp/Info.plist @@ -17,11 +17,7 @@ CFBundlePackageType APPL CFBundleShortVersionString -<<<<<<< HEAD $(LOOP_MARKETING_VERSION) -======= - 1.9.6 ->>>>>>> 5df0b4c1110b99947461e6b5047930b18582128d CFBundleSignature ???? CFBundleVersion From ab440e9e83417eaaa6dc0293d7bf73d362e5c5f5 Mon Sep 17 00:00:00 2001 From: AQ Date: Mon, 23 Dec 2019 15:16:02 -0800 Subject: [PATCH 02/18] Add what I have --- Loop/Managers/WatchDataManager.swift | 98 ++++++++++++++++++++++++++-- 1 file changed, 91 insertions(+), 7 deletions(-) diff --git a/Loop/Managers/WatchDataManager.swift b/Loop/Managers/WatchDataManager.swift index 34f563fa57..a8607e26f2 100644 --- a/Loop/Managers/WatchDataManager.swift +++ b/Loop/Managers/WatchDataManager.swift @@ -12,6 +12,10 @@ import WatchConnectivity import LoopKit import LoopCore +enum SleepStoreResult { + case success(T) + case failure(Error) +} final class WatchDataManager: NSObject { @@ -29,7 +33,23 @@ final class WatchDataManager: NSObject { watchSession?.activate() } + let healthStore = HKHealthStore() private let log: CategoryLogger + + func authorize(_ completion: @escaping () -> Void) { + let typestoRead = Set([ + HKObjectType.categoryType(forIdentifier: HKCategoryTypeIdentifier.sleepAnalysis)! + ]) + + healthStore.requestAuthorization(toShare: typestoRead, read: typestoRead) { (success, error) in + if success { + self.log.default("Asked about sleep data access") + } + + completion() + } + } + private var watchSession: WCSession? = { if WCSession.isSupported() { @@ -40,6 +60,9 @@ final class WatchDataManager: NSObject { }() private var lastSentSettings: LoopSettings? + + private var lastBedtimeUpdate: Date? + public var bedtime: Int? @objc private func updateWatch(_ notification: Notification) { guard @@ -48,6 +71,7 @@ final class WatchDataManager: NSObject { else { return } + switch updateContext { case .glucose, .tempBasal: @@ -57,12 +81,34 @@ final class WatchDataManager: NSObject { default: break } + + guard + let lastUpdateInterval = lastBedtimeUpdate?.timeIntervalSince(Date()), lastUpdateInterval < TimeInterval(hours: 24) + else { + let monthsToGoBack = -6 + let start = Calendar.current.date(byAdding: .month, value: monthsToGoBack, to: Date())! + + getSleepStartTime(start: start) { + (result) in + + switch result { + case .success(let secondsToBedtime): + self.bedtime = secondsToBedtime + case .failure: + return + } + } + + lastBedtimeUpdate = Date() + return + } } private var lastComplicationContext: WatchContext? private let minTrendDrift: Double = 20 private lazy var minTrendUnit = HKUnit.milligramsPerDeciliter + private func sendSettingsIfNeeded() { let settings = deviceManager.loopManager.settings @@ -189,7 +235,7 @@ final class WatchDataManager: NSObject { completion(context) } } - + private func addCarbEntryFromWatchMessage(_ message: [String: Any], completionHandler: ((_ error: Error?) -> Void)? = nil) { if let carbEntry = CarbEntryUserInfo(rawValue: message)?.carbEntry { deviceManager.loopManager.addCarbEntryAndRecommendBolus(carbEntry) { (result) in @@ -317,6 +363,7 @@ extension WatchDataManager: WCSessionDelegate { extension WatchDataManager { + override var debugDescription: String { var items = [ "## WatchDataManager", @@ -334,6 +381,42 @@ extension WatchDataManager { return items.joined(separator: "\n") } + + func getSleepStartTime(start: Date, end: Date? = nil, sampleLimit: Int = 30, _ completion: @escaping (_ result: SleepStoreResult) -> 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) -> 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] { + let average = samples.reduce(0, {$0 + $1.startDate.secondsFromMidnight()}) / samples.count + completion(.success(average)) + } else { + assertionFailure("Unknown return configuration from query \(query)") + } + } + + healthStore.execute(query) + } + +} + +extension Date { + 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 + } } @@ -354,17 +437,18 @@ extension WCSession { "* watchDirectoryURL: \(watchDirectoryURL?.absoluteString ?? "nil")", ].joined(separator: "\n") } - + fileprivate var complicationUserInfoTransferInterval: TimeInterval { let now = Date() - let timeUntilMidnight: TimeInterval - + let timeUntilRefresh: TimeInterval + if let midnight = Calendar.current.nextDate(after: now, matching: DateComponents(hour: 0), matchingPolicy: .nextTime) { - timeUntilMidnight = midnight.timeIntervalSince(now) + timeUntilRefresh = midnight.timeIntervalSince(now) } else { - timeUntilMidnight = .hours(24) + timeUntilRefresh = .hours(24) } - return timeUntilMidnight / Double(remainingComplicationUserInfoTransfers + 1) + return timeUntilRefresh / Double(remainingComplicationUserInfoTransfers + 1) } } + From c1512d4a4fa63024354341dd214c5562b38c730f Mon Sep 17 00:00:00 2001 From: AQ Date: Wed, 25 Dec 2019 09:48:39 -0800 Subject: [PATCH 03/18] Add sleep permission --- Loop/Managers/LoopDataManager.swift | 25 ++++++++++++++++++++++--- 1 file changed, 22 insertions(+), 3 deletions(-) diff --git a/Loop/Managers/LoopDataManager.swift b/Loop/Managers/LoopDataManager.swift index a7f02eaae9..5600e5d95d 100644 --- a/Loop/Managers/LoopDataManager.swift +++ b/Loop/Managers/LoopDataManager.swift @@ -269,6 +269,15 @@ final class LoopDataManager { backgroundTask = .invalid } } + + private func loopDidComplete(date: Date, duration: TimeInterval) { + lastLoopCompleted = date + NotificationManager.clearLoopNotRunningNotifications() + NotificationManager.scheduleLoopNotRunningNotifications() + AnalyticsManager.shared.loopDidSucceed(duration) + NotificationCenter.default.post(name: .LoopCompleted, object: self) + + } } // MARK: Background task management @@ -413,8 +422,18 @@ extension LoopDataManager { } } - /// All the HealthKit types to be read and shared by stores - private var sampleTypes: Set { + /// All the HealthKit types to be read by stores + private var readTypes: Set { + return Set([ + glucoseStore.sampleType, + carbStore.sampleType, + doseStore.sampleType, + HKObjectType.categoryType(forIdentifier: HKCategoryTypeIdentifier.sleepAnalysis)! + ].compactMap { $0 }) + } + + /// All the HealthKit types to be shared by stores + private var shareTypes: Set { return Set([ glucoseStore.sampleType, carbStore.sampleType, @@ -438,7 +457,7 @@ extension LoopDataManager { func authorize(_ completion: @escaping () -> Void) { // Authorize all types at once for simplicity - carbStore.healthStore.requestAuthorization(toShare: sampleTypes, read: sampleTypes) { (success, error) in + carbStore.healthStore.requestAuthorization(toShare: shareTypes, read: readTypes) { (success, error) in if success { // Call the individual authorization methods to trigger query creation self.carbStore.authorize({ _ in }) From ee2227a1a1eee5ddc61c37ea228081e6783fffab Mon Sep 17 00:00:00 2001 From: AQ Date: Wed, 25 Dec 2019 09:48:56 -0800 Subject: [PATCH 04/18] Refine complication math --- Loop/Managers/WatchDataManager.swift | 85 ++++++++++++++-------------- 1 file changed, 42 insertions(+), 43 deletions(-) diff --git a/Loop/Managers/WatchDataManager.swift b/Loop/Managers/WatchDataManager.swift index a8607e26f2..79d5f392b9 100644 --- a/Loop/Managers/WatchDataManager.swift +++ b/Loop/Managers/WatchDataManager.swift @@ -35,21 +35,6 @@ final class WatchDataManager: NSObject { let healthStore = HKHealthStore() private let log: CategoryLogger - - func authorize(_ completion: @escaping () -> Void) { - let typestoRead = Set([ - HKObjectType.categoryType(forIdentifier: HKCategoryTypeIdentifier.sleepAnalysis)! - ]) - - healthStore.requestAuthorization(toShare: typestoRead, read: typestoRead) { (success, error) in - if success { - self.log.default("Asked about sleep data access") - } - - completion() - } - } - private var watchSession: WCSession? = { if WCSession.isSupported() { @@ -62,26 +47,9 @@ final class WatchDataManager: NSObject { private var lastSentSettings: LoopSettings? private var lastBedtimeUpdate: Date? - public var bedtime: Int? - - @objc private func updateWatch(_ notification: Notification) { - guard - let rawUpdateContext = notification.userInfo?[LoopDataManager.LoopUpdateContextKey] as? LoopDataManager.LoopUpdateContext.RawValue, - let updateContext = LoopDataManager.LoopUpdateContext(rawValue: rawUpdateContext) - else { - return - } - - - switch updateContext { - case .glucose, .tempBasal: - sendWatchContextIfNeeded() - case .preferences: - sendSettingsIfNeeded() - default: - break - } - + private var bedtime: TimeInterval? + + private func updateBedtime() { guard let lastUpdateInterval = lastBedtimeUpdate?.timeIntervalSince(Date()), lastUpdateInterval < TimeInterval(hours: 24) else { @@ -93,7 +61,7 @@ final class WatchDataManager: NSObject { switch result { case .success(let secondsToBedtime): - self.bedtime = secondsToBedtime + self.bedtime = TimeInterval(secondsToBedtime) case .failure: return } @@ -104,6 +72,25 @@ final class WatchDataManager: NSObject { } } + @objc private func updateWatch(_ notification: Notification) { + guard + let rawUpdateContext = notification.userInfo?[LoopDataManager.LoopUpdateContextKey] as? LoopDataManager.LoopUpdateContext.RawValue, + let updateContext = LoopDataManager.LoopUpdateContext(rawValue: rawUpdateContext) + else { + return + } + + + switch updateContext { + case .glucose, .tempBasal: + sendWatchContextIfNeeded() + case .preferences: + sendSettingsIfNeeded() + default: + break + } + } + private var lastComplicationContext: WatchContext? private let minTrendDrift: Double = 20 @@ -157,6 +144,8 @@ final class WatchDataManager: NSObject { session.activate() return } + + updateBedtime() let complicationShouldUpdate: Bool @@ -164,7 +153,7 @@ final class WatchDataManager: NSObject { let lastGlucose = lastContext.glucose, let lastGlucoseDate = lastContext.glucoseDate, let newGlucose = context.glucose, let newGlucoseDate = context.glucoseDate { - let enoughTimePassed = newGlucoseDate.timeIntervalSince(lastGlucoseDate) >= session.complicationUserInfoTransferInterval + let enoughTimePassed = newGlucoseDate.timeIntervalSince(lastGlucoseDate) >= session.complicationUserInfoTransferInterval(bedtime: bedtime) let enoughTrendDrift = abs(newGlucose.doubleValue(for: minTrendUnit) - lastGlucose.doubleValue(for: minTrendUnit)) >= minTrendDrift complicationShouldUpdate = enoughTimePassed || enoughTrendDrift @@ -369,6 +358,8 @@ extension WatchDataManager { "## WatchDataManager", "lastSentSettings: \(String(describing: lastSentSettings))", "lastComplicationContext: \(String(describing: lastComplicationContext))", + "bedtime: \(String(describing: bedtime))", + "* complicationUserInfoTransferInterval: \(round(watchSession?.complicationUserInfoTransferInterval(bedtime: nil).minutes ?? 0)) min" ] if let session = watchSession { @@ -382,7 +373,7 @@ extension WatchDataManager { return items.joined(separator: "\n") } - func getSleepStartTime(start: Date, end: Date? = nil, sampleLimit: Int = 30, _ completion: @escaping (_ result: SleepStoreResult) -> Void) { + private func getSleepStartTime(start: Date, end: Date? = nil, sampleLimit: Int = 30, _ completion: @escaping (_ result: SleepStoreResult) -> Void) { let predicate = HKQuery.predicateForSamples(withStart: start, end: end, options: []) getSleepStartTime(matching: predicate, sampleLimit: sampleLimit, completion) @@ -410,7 +401,7 @@ extension WatchDataManager { } extension Date { - func secondsFromMidnight() -> Int { + 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! @@ -433,17 +424,26 @@ extension WCSession { "* outstandingUserInfoTransfers: \(outstandingUserInfoTransfers)", "* receivedApplicationContext: \(receivedApplicationContext)", "* remainingComplicationUserInfoTransfers: \(remainingComplicationUserInfoTransfers)", - "* complicationUserInfoTransferInterval: \(round(complicationUserInfoTransferInterval.minutes)) min", "* watchDirectoryURL: \(watchDirectoryURL?.absoluteString ?? "nil")", ].joined(separator: "\n") } - fileprivate var complicationUserInfoTransferInterval: TimeInterval { + fileprivate func complicationUserInfoTransferInterval(bedtime: TimeInterval?) -> TimeInterval { let now = Date() let timeUntilRefresh: TimeInterval + // TODO: this is a really convoluted structure if let midnight = Calendar.current.nextDate(after: now, matching: DateComponents(hour: 0), matchingPolicy: .nextTime) { - timeUntilRefresh = midnight.timeIntervalSince(now) + // 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) + if let bedTime = bedtime { + let bedtimeToday = Calendar.current.startOfDay(for: now).addingTimeInterval(bedTime) + let timeUntilBedtime = bedtimeToday.timeIntervalSince(now) + timeUntilRefresh = timeUntilBedtime > TimeInterval(0) ? timeUntilBedtime : midnight.timeIntervalSince(now) + } + // otherwise, since (in most cases) the complications allowance refreshes at midnight, base it on the time remaining until midnight + else { + timeUntilRefresh = midnight.timeIntervalSince(now) + } } else { timeUntilRefresh = .hours(24) } @@ -451,4 +451,3 @@ extension WCSession { return timeUntilRefresh / Double(remainingComplicationUserInfoTransfers + 1) } } - From 0658c47d287855aa25422e08d75460d534d96e9e Mon Sep 17 00:00:00 2001 From: AQ Date: Thu, 26 Dec 2019 20:05:17 -0800 Subject: [PATCH 05/18] Improvements to complication-refresh code --- Loop/Base.lproj/InfoPlist.strings | 2 +- Loop/Managers/LoopDataManager.swift | 16 +++++++--------- Loop/Managers/WatchDataManager.swift | 28 +++++++++++++--------------- 3 files changed, 21 insertions(+), 25 deletions(-) diff --git a/Loop/Base.lproj/InfoPlist.strings b/Loop/Base.lproj/InfoPlist.strings index 14e4d82681..72b924b54a 100644 --- a/Loop/Base.lproj/InfoPlist.strings +++ b/Loop/Base.lproj/InfoPlist.strings @@ -9,7 +9,7 @@ "NSFaceIDUsageDescription" = "Face ID is used to authenticate insulin bolus."; /* Privacy - Health Share Usage Description */ -"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."; +"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."; /* Privacy - Health Update Usage Description */ "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."; diff --git a/Loop/Managers/LoopDataManager.swift b/Loop/Managers/LoopDataManager.swift index 5600e5d95d..4d743b7dc3 100644 --- a/Loop/Managers/LoopDataManager.swift +++ b/Loop/Managers/LoopDataManager.swift @@ -91,7 +91,7 @@ final class LoopDataManager { // Observe changes notificationObservers = [ NotificationCenter.default.addObserver( - forName: .CarbEntriesDidUpdate, + forName: CarbStore.carbEntriesDidUpdate, object: carbStore, queue: nil ) { (note) -> Void in @@ -104,7 +104,7 @@ final class LoopDataManager { } }, NotificationCenter.default.addObserver( - forName: .GlucoseSamplesDidChange, + forName: GlucoseStore.glucoseSamplesDidChange, object: glucoseStore, queue: nil ) { (note) in @@ -216,6 +216,8 @@ final class LoopDataManager { private let lockedBasalDeliveryState: Locked fileprivate var lastRequestedBolus: DoseEntry? + + private var lastLoopStarted: Date? /// The last date at which a loop completed, from prediction to dose (if dosing is enabled) var lastLoopCompleted: Date? { @@ -224,11 +226,6 @@ final class LoopDataManager { } set { lockedLastLoopCompleted.value = newValue - - NotificationManager.clearLoopNotRunningNotifications() - NotificationManager.scheduleLoopNotRunningNotifications() - AnalyticsManager.shared.loopDidSucceed() - NotificationCenter.default.post(name: .LoopCompleted, object: self) } } private let lockedLastLoopCompleted: Locked @@ -654,6 +651,7 @@ extension LoopDataManager { NotificationCenter.default.post(name: .LoopRunning, object: self) self.lastLoopError = nil + let startDate = Date() do { try self.update() @@ -665,7 +663,7 @@ extension LoopDataManager { if let error = error { self.logger.error(error) } else { - self.lastLoopCompleted = Date() + self.loopDidComplete(date: Date(), duration: -startDate.timeIntervalSinceNow) } self.logger.default("Loop ended") self.notify(forChange: .tempBasal) @@ -674,7 +672,7 @@ extension LoopDataManager { // Delay the notification until we know the result of the temp basal return } else { - self.lastLoopCompleted = Date() + self.loopDidComplete(date: Date(), duration: -startDate.timeIntervalSinceNow) } } catch let error { self.lastLoopError = error diff --git a/Loop/Managers/WatchDataManager.swift b/Loop/Managers/WatchDataManager.swift index 79d5f392b9..dacbac57ea 100644 --- a/Loop/Managers/WatchDataManager.swift +++ b/Loop/Managers/WatchDataManager.swift @@ -33,7 +33,6 @@ final class WatchDataManager: NSObject { watchSession?.activate() } - let healthStore = HKHealthStore() private let log: CategoryLogger private var watchSession: WCSession? = { @@ -46,19 +45,22 @@ final class WatchDataManager: NSObject { private var lastSentSettings: LoopSettings? - private var lastBedtimeUpdate: Date? + let healthStore = HKHealthStore() + private var lastBedtimeUpdate: Date = Calendar.current.date(byAdding: .hour, value: -25, to: Date())! private var bedtime: TimeInterval? private func updateBedtime() { + let lastUpdateInterval = Date().timeIntervalSince(lastBedtimeUpdate) guard - let lastUpdateInterval = lastBedtimeUpdate?.timeIntervalSince(Date()), lastUpdateInterval < TimeInterval(hours: 24) + 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 - + switch result { case .success(let secondsToBedtime): self.bedtime = TimeInterval(secondsToBedtime) @@ -79,7 +81,6 @@ final class WatchDataManager: NSObject { else { return } - switch updateContext { case .glucose, .tempBasal: @@ -95,7 +96,6 @@ final class WatchDataManager: NSObject { private let minTrendDrift: Double = 20 private lazy var minTrendUnit = HKUnit.milligramsPerDeciliter - private func sendSettingsIfNeeded() { let settings = deviceManager.loopManager.settings @@ -144,10 +144,9 @@ final class WatchDataManager: NSObject { session.activate() return } - - updateBedtime() let complicationShouldUpdate: Bool + updateBedtime() if let lastContext = lastComplicationContext, let lastGlucose = lastContext.glucose, let lastGlucoseDate = lastContext.glucoseDate, @@ -224,7 +223,7 @@ final class WatchDataManager: NSObject { completion(context) } } - + private func addCarbEntryFromWatchMessage(_ message: [String: Any], completionHandler: ((_ error: Error?) -> Void)? = nil) { if let carbEntry = CarbEntryUserInfo(rawValue: message)?.carbEntry { deviceManager.loopManager.addCarbEntryAndRecommendBolus(carbEntry) { (result) in @@ -352,14 +351,13 @@ extension WatchDataManager: WCSessionDelegate { extension WatchDataManager { - override var debugDescription: String { var items = [ "## WatchDataManager", "lastSentSettings: \(String(describing: lastSentSettings))", "lastComplicationContext: \(String(describing: lastComplicationContext))", - "bedtime: \(String(describing: bedtime))", - "* complicationUserInfoTransferInterval: \(round(watchSession?.complicationUserInfoTransferInterval(bedtime: nil).minutes ?? 0)) min" + "bedtime: at around \(round((bedtime?.hours ?? 0) * 100) / 100) o'clock", + "complicationUserInfoTransferInterval: \(round(watchSession?.complicationUserInfoTransferInterval(bedtime: bedtime).minutes ?? 0)) min" ] if let session = watchSession { @@ -385,6 +383,7 @@ extension WatchDataManager { 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] { @@ -431,8 +430,7 @@ extension WCSession { fileprivate func complicationUserInfoTransferInterval(bedtime: TimeInterval?) -> TimeInterval { let now = Date() let timeUntilRefresh: TimeInterval - - // TODO: this is a really convoluted structure + if let midnight = Calendar.current.nextDate(after: now, matching: DateComponents(hour: 0), matchingPolicy: .nextTime) { // 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) if let bedTime = bedtime { @@ -447,7 +445,7 @@ extension WCSession { } else { timeUntilRefresh = .hours(24) } - + return timeUntilRefresh / Double(remainingComplicationUserInfoTransfers + 1) } } From e503af413d96f21cc2167f6593534077e7fc637d Mon Sep 17 00:00:00 2001 From: AQ Date: Thu, 26 Dec 2019 20:16:09 -0800 Subject: [PATCH 06/18] Update to match dev --- Cartfile | 13 ++++++------- Loop Status Extension/Info.plist | 2 +- Loop/Info.plist | 4 +--- 3 files changed, 8 insertions(+), 11 deletions(-) diff --git a/Cartfile b/Cartfile index d96c224279..a6ffb0293b 100644 --- a/Cartfile +++ b/Cartfile @@ -1,7 +1,6 @@ -github "LoopKit/LoopKit" ~> 2.2 -github "LoopKit/CGMBLEKit" ~> 3.1 -github "ps2/SwiftCharts" "hotfix-xcode10.2" -github "LoopKit/dexcom-share-client-swift" == 1.0 -github "LoopKit/G4ShareSpy" == 1.0 -github "ps2/rileylink_ios" ~> 2.1 -github "LoopKit/Amplitude-iOS" "decreepify" +github "LoopKit/LoopKit" "dev" +github "LoopKit/CGMBLEKit" "dev" +github "i-schuetz/SwiftCharts" == 0.6.5 +github "LoopKit/dexcom-share-client-swift" "dev" +github "LoopKit/G4ShareSpy" "dev" +github "ps2/rileylink_ios" "dev" diff --git a/Loop Status Extension/Info.plist b/Loop Status Extension/Info.plist index a829537cb1..1c13816bf6 100644 --- a/Loop Status Extension/Info.plist +++ b/Loop Status Extension/Info.plist @@ -19,7 +19,7 @@ CFBundlePackageType XPC! CFBundleShortVersionString - 1.9.6 + $(LOOP_MARKETING_VERSION) CFBundleVersion $(CURRENT_PROJECT_VERSION) MainAppBundleIdentifier diff --git a/Loop/Info.plist b/Loop/Info.plist index 65ab6fccbe..f8907e07d0 100644 --- a/Loop/Info.plist +++ b/Loop/Info.plist @@ -19,7 +19,7 @@ CFBundlePackageType APPL CFBundleShortVersionString - 1.9.6 + $(LOOP_MARKETING_VERSION) CFBundleSignature ???? CFBundleURLTypes @@ -47,8 +47,6 @@ Bluetooth is used to communicate with insulin pump and continuous glucose monitor devices NSBluetoothPeripheralUsageDescription Bluetooth is used to communicate with insulin pump and continuous glucose monitor devices - NSBluetoothAlwaysUsageDescription - Bluetooth is used to communicate with insulin pump and continuous glucose monitor devices NSFaceIDUsageDescription Face ID is used to authenticate insulin bolus. NSHealthShareUsageDescription From 2e742d7a588baa2e3aac4f78f27e4d0053abf865 Mon Sep 17 00:00:00 2001 From: AQ Date: Thu, 26 Dec 2019 20:17:18 -0800 Subject: [PATCH 07/18] Make cartfile accurate --- Cartfile | 1 + 1 file changed, 1 insertion(+) diff --git a/Cartfile b/Cartfile index a6ffb0293b..8d7df552c6 100644 --- a/Cartfile +++ b/Cartfile @@ -4,3 +4,4 @@ github "i-schuetz/SwiftCharts" == 0.6.5 github "LoopKit/dexcom-share-client-swift" "dev" github "LoopKit/G4ShareSpy" "dev" github "ps2/rileylink_ios" "dev" +github "LoopKit/Amplitude-iOS" "decreepify" \ No newline at end of file From 53c98a8e3e9021f9219de61e5c06bddd84cc4f76 Mon Sep 17 00:00:00 2001 From: AQ Date: Thu, 26 Dec 2019 20:17:51 -0800 Subject: [PATCH 08/18] Add newline --- Cartfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Cartfile b/Cartfile index 8d7df552c6..ca66f13586 100644 --- a/Cartfile +++ b/Cartfile @@ -4,4 +4,4 @@ github "i-schuetz/SwiftCharts" == 0.6.5 github "LoopKit/dexcom-share-client-swift" "dev" github "LoopKit/G4ShareSpy" "dev" github "ps2/rileylink_ios" "dev" -github "LoopKit/Amplitude-iOS" "decreepify" \ No newline at end of file +github "LoopKit/Amplitude-iOS" "decreepify" From a65f586349c73d25dfd3c9738f708af6967bad7e Mon Sep 17 00:00:00 2001 From: AQ Date: Sat, 28 Dec 2019 15:47:06 -0800 Subject: [PATCH 09/18] TimeInterval -> Date --- Loop/Managers/WatchDataManager.swift | 32 ++++++++++++++++++---------- 1 file changed, 21 insertions(+), 11 deletions(-) diff --git a/Loop/Managers/WatchDataManager.swift b/Loop/Managers/WatchDataManager.swift index dacbac57ea..262c81e155 100644 --- a/Loop/Managers/WatchDataManager.swift +++ b/Loop/Managers/WatchDataManager.swift @@ -47,7 +47,7 @@ final class WatchDataManager: NSObject { let healthStore = HKHealthStore() private var lastBedtimeUpdate: Date = Calendar.current.date(byAdding: .hour, value: -25, to: Date())! - private var bedtime: TimeInterval? + private var bedtime: Date? private func updateBedtime() { let lastUpdateInterval = Date().timeIntervalSince(lastBedtimeUpdate) @@ -62,13 +62,14 @@ final class WatchDataManager: NSObject { (result) in switch result { - case .success(let secondsToBedtime): - self.bedtime = TimeInterval(secondsToBedtime) + case .success(let bedtime): + self.bedtime = bedtime case .failure: return } } + // update when we last checked the bedtime lastBedtimeUpdate = Date() return } @@ -356,7 +357,7 @@ extension WatchDataManager { "## WatchDataManager", "lastSentSettings: \(String(describing: lastSentSettings))", "lastComplicationContext: \(String(describing: lastComplicationContext))", - "bedtime: at around \(round((bedtime?.hours ?? 0) * 100) / 100) o'clock", + "bedtime: \(String(describing: bedtime))", "complicationUserInfoTransferInterval: \(round(watchSession?.complicationUserInfoTransferInterval(bedtime: bedtime).minutes ?? 0)) min" ] @@ -371,13 +372,13 @@ extension WatchDataManager { return items.joined(separator: "\n") } - private func getSleepStartTime(start: Date, end: Date? = nil, sampleLimit: Int = 30, _ completion: @escaping (_ result: SleepStoreResult) -> Void) { + private func getSleepStartTime(start: Date, end: Date? = nil, sampleLimit: Int = 30, _ completion: @escaping (_ result: SleepStoreResult) -> 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) -> Void) { + private func getSleepStartTime(matching predicate: NSPredicate, sampleLimit: Int, _ completion: @escaping (_ result: SleepStoreResult) -> Void) { let sleepType = HKObjectType.categoryType(forIdentifier: HKCategoryTypeIdentifier.sleepAnalysis)! // get more-recent values first let sortDescriptor = NSSortDescriptor(key: HKSampleSortIdentifierEndDate, ascending: false) @@ -387,8 +388,18 @@ extension WatchDataManager { 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 - completion(.success(average)) + 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) { + print("Bedtime:", time) + completion(.success(time)) + } else { + completion(.failure(NSError())) + } } else { assertionFailure("Unknown return configuration from query \(query)") } @@ -427,15 +438,14 @@ extension WCSession { ].joined(separator: "\n") } - fileprivate func complicationUserInfoTransferInterval(bedtime: TimeInterval?) -> TimeInterval { + fileprivate func complicationUserInfoTransferInterval(bedtime: Date?) -> TimeInterval { let now = Date() let timeUntilRefresh: TimeInterval if let midnight = Calendar.current.nextDate(after: now, matching: DateComponents(hour: 0), matchingPolicy: .nextTime) { // 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) - if let bedTime = bedtime { - let bedtimeToday = Calendar.current.startOfDay(for: now).addingTimeInterval(bedTime) - let timeUntilBedtime = bedtimeToday.timeIntervalSince(now) + if let nextBedtime = bedtime { + let timeUntilBedtime = nextBedtime.timeIntervalSince(now) timeUntilRefresh = timeUntilBedtime > TimeInterval(0) ? timeUntilBedtime : midnight.timeIntervalSince(now) } // otherwise, since (in most cases) the complications allowance refreshes at midnight, base it on the time remaining until midnight From cc1704966ec834e44bff49b12102d6ba2fc0fdba Mon Sep 17 00:00:00 2001 From: AQ Date: Sat, 28 Dec 2019 15:51:19 -0800 Subject: [PATCH 10/18] Ensure last update time is updated in case of failure --- Loop/Managers/WatchDataManager.swift | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/Loop/Managers/WatchDataManager.swift b/Loop/Managers/WatchDataManager.swift index 262c81e155..d80d9f3316 100644 --- a/Loop/Managers/WatchDataManager.swift +++ b/Loop/Managers/WatchDataManager.swift @@ -61,6 +61,9 @@ final class WatchDataManager: NSObject { getSleepStartTime(start: start) { (result) in + // update when we last checked the bedtime + self.lastBedtimeUpdate = Date() + switch result { case .success(let bedtime): self.bedtime = bedtime @@ -69,8 +72,6 @@ final class WatchDataManager: NSObject { } } - // update when we last checked the bedtime - lastBedtimeUpdate = Date() return } } From 28795e54825850f88d3f5c027edc8c94cba39d7b Mon Sep 17 00:00:00 2001 From: AQ Date: Sat, 28 Dec 2019 16:07:55 -0800 Subject: [PATCH 11/18] Remove print statement --- Loop/Managers/WatchDataManager.swift | 1 - 1 file changed, 1 deletion(-) diff --git a/Loop/Managers/WatchDataManager.swift b/Loop/Managers/WatchDataManager.swift index d80d9f3316..068244cc9c 100644 --- a/Loop/Managers/WatchDataManager.swift +++ b/Loop/Managers/WatchDataManager.swift @@ -396,7 +396,6 @@ extension WatchDataManager { // 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) { - print("Bedtime:", time) completion(.success(time)) } else { completion(.failure(NSError())) From ec14d2ed120903a6ddfc1d10cd2f33a082d9107a Mon Sep 17 00:00:00 2001 From: AQ Date: Sun, 29 Dec 2019 20:40:33 -0800 Subject: [PATCH 12/18] Changes based on review --- Loop.xcodeproj/project.pbxproj | 4 + Loop/Managers/SleepStore.swift | 121 +++++++++++++++++++++++++++ Loop/Managers/WatchDataManager.swift | 119 ++++++++++---------------- LoopCore/NSUserDefaults.swift | 28 +++++++ 4 files changed, 197 insertions(+), 75 deletions(-) create mode 100644 Loop/Managers/SleepStore.swift diff --git a/Loop.xcodeproj/project.pbxproj b/Loop.xcodeproj/project.pbxproj index 18532c5a47..380a4b61cb 100644 --- a/Loop.xcodeproj/project.pbxproj +++ b/Loop.xcodeproj/project.pbxproj @@ -393,6 +393,7 @@ C1FB428D21791D2500FAB378 /* PumpManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43C3B6F620BBCAA30026CAFA /* PumpManager.swift */; }; C1FB428F217921D600FAB378 /* PumpManagerUI.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1FB428E217921D600FAB378 /* PumpManagerUI.swift */; }; C1FB4290217922A100FAB378 /* PumpManagerUI.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1FB428E217921D600FAB378 /* PumpManagerUI.swift */; }; + E9BB27AB23B85C3500FB4987 /* SleepStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9BB27AA23B85C3500FB4987 /* SleepStore.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -1048,6 +1049,7 @@ C1F8B1DB223862D500DD66CF /* BolusProgressTableViewCell.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = BolusProgressTableViewCell.xib; sourceTree = ""; }; C1FB428B217806A300FAB378 /* StateColorPalette.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = StateColorPalette.swift; sourceTree = ""; }; C1FB428E217921D600FAB378 /* PumpManagerUI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PumpManagerUI.swift; sourceTree = ""; }; + E9BB27AA23B85C3500FB4987 /* SleepStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SleepStore.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -1602,6 +1604,7 @@ 4F70C20F1DE8FAC5006380B7 /* StatusExtensionDataManager.swift */, 89ADE13A226BFA0F0067222B /* TestingScenariosManager.swift */, 4328E0341CFC0AE100E199AA /* WatchDataManager.swift */, + E9BB27AA23B85C3500FB4987 /* SleepStore.swift */, ); path = Managers; sourceTree = ""; @@ -2577,6 +2580,7 @@ 430B29932041F5B300BA9F93 /* UserDefaults+Loop.swift in Sources */, 4341F4EB1EDB92AC001C936B /* LogglyService.swift in Sources */, 43CE7CDE1CA8B63E003CC1B0 /* Data.swift in Sources */, + E9BB27AB23B85C3500FB4987 /* SleepStore.swift in Sources */, C1F8B243223E73FD00DD66CF /* BolusProgressTableViewCell.swift in Sources */, 89CA2B30226C0161004D9350 /* DirectoryObserver.swift in Sources */, 439A7942211F631C0041B75F /* RootNavigationController.swift in Sources */, diff --git a/Loop/Managers/SleepStore.swift b/Loop/Managers/SleepStore.swift new file mode 100644 index 0000000000..eaa614677a --- /dev/null +++ b/Loop/Managers/SleepStore.swift @@ -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 { + case success(T) + case failure(Error) +} + +enum SleepStoreError: Error { + case noMatchingBedtime + case unknownReturnConfiguration + case noSleepDataAvailable +} + +extension SleepStoreError: LocalizedError { + public var localizedDescription: String { + 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 + + public init( + healthStore: HKHealthStore, + sampleLimit: Int = 30 + ) { + self.healthStore = healthStore + self.sampleLimit = sampleLimit + } + + func getAverageSleepStartTime(_ completion: @escaping (_ result: SleepStoreResult) -> 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) -> 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 + let dateComponents = calendar.dateComponents([.hour, .minute, .second], from: self) + let dateSeconds = dateComponents.hour! * 3600 + dateComponents.minute! * 60 + dateComponents.second! + + return dateSeconds + } +} diff --git a/Loop/Managers/WatchDataManager.swift b/Loop/Managers/WatchDataManager.swift index 068244cc9c..2bd6a538b8 100644 --- a/Loop/Managers/WatchDataManager.swift +++ b/Loop/Managers/WatchDataManager.swift @@ -12,17 +12,16 @@ import WatchConnectivity import LoopKit import LoopCore -enum SleepStoreResult { - 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 + 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) { + 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) -> 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) -> 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 [ diff --git a/LoopCore/NSUserDefaults.swift b/LoopCore/NSUserDefaults.swift index 54db2d46c5..38f6158874 100644 --- a/LoopCore/NSUserDefaults.swift +++ b/LoopCore/NSUserDefaults.swift @@ -20,6 +20,8 @@ extension UserDefaults { case loopSettings = "com.loopkit.Loop.loopSettings" case insulinSensitivitySchedule = "com.loudnate.Naterade.InsulinSensitivitySchedule" case overrideHistory = "com.tidepool.loopkit.overrideHistory" + case lastBedtimeQuery = "com.loopkit.Loop.lastBedtimeQuery" + case bedtime = "com.loopkit.Loop.bedtime" } public static let appGroup = UserDefaults(suiteName: Bundle.main.appGroupSuiteName) @@ -151,4 +153,30 @@ extension UserDefaults { set(newValue?.rawValue, forKey: Key.overrideHistory.rawValue) } } + + public var lastBedtimeQuery: Date? { + get { + if let rawValue = object(forKey: Key.lastBedtimeQuery.rawValue) as? Date { + return rawValue + } else { + return nil + } + } + set { + set(newValue, forKey: Key.lastBedtimeQuery.rawValue) + } + } + + public var bedtime: Date? { + get { + if let rawValue = object(forKey: Key.bedtime.rawValue) as? Date { + return rawValue + } else { + return nil + } + } + set { + set(newValue, forKey: Key.bedtime.rawValue) + } + } } From e70a89ed850516002b4c76b8bf4a20b0a29de06d Mon Sep 17 00:00:00 2001 From: AQ Date: Tue, 31 Dec 2019 13:31:36 -0800 Subject: [PATCH 13/18] More changes in response to review --- Loop/Managers/SleepStore.swift | 42 ++++++++++++---------------- Loop/Managers/WatchDataManager.swift | 15 +++++----- 2 files changed, 26 insertions(+), 31 deletions(-) diff --git a/Loop/Managers/SleepStore.swift b/Loop/Managers/SleepStore.swift index eaa614677a..571c9b5d10 100644 --- a/Loop/Managers/SleepStore.swift +++ b/Loop/Managers/SleepStore.swift @@ -11,7 +11,7 @@ import HealthKit enum SleepStoreResult { case success(T) - case failure(Error) + case failure(SleepStoreError) } enum SleepStoreError: Error { @@ -20,32 +20,16 @@ enum SleepStoreError: Error { case noSleepDataAvailable } -extension SleepStoreError: LocalizedError { - public var localizedDescription: String { - 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 public init( - healthStore: HKHealthStore, - sampleLimit: Int = 30 + healthStore: HKHealthStore ) { self.healthStore = healthStore - self.sampleLimit = sampleLimit } - func getAverageSleepStartTime(_ completion: @escaping (_ result: SleepStoreResult) -> Void) { + func getAverageSleepStartTime(sampleLimit: Int = 30, _ completion: @escaping (_ result: SleepStoreResult) -> Void) { let inBedPredicate = HKQuery.predicateForCategorySamples( with: .equalTo, value: HKCategoryValueSleepAnalysis.inBed.rawValue @@ -65,7 +49,7 @@ class SleepStore { 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) + self.getAverageSleepStartTime(matching: asleepPredicate, sampleLimit: sampleLimit, completion) default: // otherwise, call completion completion(result) @@ -84,7 +68,7 @@ class SleepStore { let query = HKSampleQuery(sampleType: sleepType, predicate: predicate, limit: sampleLimit, sortDescriptors: [sortByDate]) { (query, samples, error) in if let error = error { - completion(.failure(error)) + completion(.failure(error as! SleepStoreError)) } else if let samples = samples as? [HKCategorySample] { guard !samples.isEmpty else { completion(.failure(SleepStoreError.noSleepDataAvailable)) @@ -92,7 +76,15 @@ class SleepStore { } // find the average hour and minute components from the sleep start times - let average = samples.reduce(0, {$0 + $1.startDate.timeOfDayInSeconds()}) / samples.count + let average = samples.reduce(0, { + if let metadata = $1.metadata, let timezone = metadata[HKMetadataKeyTimeZone] { + return $0 + $1.startDate.timeOfDayInSeconds(sampleTimeZone: NSTimeZone(name: timezone as! String)! as TimeZone) + } else { + // default to the current timezone if the sample does not contain one in its metadata + return $0 + $1.startDate.timeOfDayInSeconds(sampleTimeZone: Calendar.current.timeZone) + } + }) / samples.count + let averageHour = average / 3600 let averageMinute = average % 3600 / 60 @@ -111,8 +103,10 @@ class SleepStore { } extension Date { - fileprivate func timeOfDayInSeconds() -> Int { - let calendar = Calendar.current + fileprivate func timeOfDayInSeconds(sampleTimeZone: TimeZone) -> Int { + var calendar = Calendar.current + calendar.timeZone = sampleTimeZone + let dateComponents = calendar.dateComponents([.hour, .minute, .second], from: self) let dateSeconds = dateComponents.hour! * 3600 + dateComponents.minute! * 60 + dateComponents.second! diff --git a/Loop/Managers/WatchDataManager.swift b/Loop/Managers/WatchDataManager.swift index 2bd6a538b8..e50aeb4c5c 100644 --- a/Loop/Managers/WatchDataManager.swift +++ b/Loop/Managers/WatchDataManager.swift @@ -18,8 +18,7 @@ final class WatchDataManager: NSObject { init(deviceManager: DeviceDataManager) { self.deviceManager = deviceManager - self.healthStore = deviceManager.loopManager.glucoseStore.healthStore - self.sleepStore = SleepStore (healthStore: healthStore) + self.sleepStore = SleepStore (healthStore: deviceManager.loopManager.glucoseStore.healthStore) self.lastBedtimeQuery = UserDefaults.appGroup?.lastBedtimeQuery ?? .distantPast self.bedtime = UserDefaults.appGroup?.bedtime self.log = DiagnosticLogger.shared.forCategory("WatchDataManager") @@ -43,8 +42,7 @@ final class WatchDataManager: NSObject { }() private var lastSentSettings: LoopSettings? - - let healthStore: HKHealthStore + let sleepStore: SleepStore var lastBedtimeQuery: Date { @@ -70,17 +68,19 @@ final class WatchDataManager: NSObject { let hourComponent = calendar.component(.hour, from: bedtime) let minuteComponent = calendar.component(.minute, from: bedtime) - if let newBedtime = calendar.nextDate(after: now, matching: DateComponents(hour: hourComponent, minute: minuteComponent), matchingPolicy: .nextTime) { + if let newBedtime = calendar.nextDate(after: now, matching: DateComponents(hour: hourComponent, minute: minuteComponent), matchingPolicy: .nextTime), newBedtime.timeIntervalSinceNow <= .hours(24) { self.bedtime = newBedtime } } - + return } sleepStore.getAverageSleepStartTime() { (result) in + self.lastBedtimeQuery = now + switch result { case .success(let bedtime): self.bedtime = bedtime @@ -415,7 +415,8 @@ extension WCSession { // 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) if let nextBedtime = bedtime { let timeUntilBedtime = nextBedtime.timeIntervalSince(now) - timeUntilRefresh = timeUntilBedtime > TimeInterval(0) ? timeUntilBedtime : midnight.timeIntervalSince(now) + // if bedtime is before the current time or more than 24 hours away, use midnight instead + timeUntilRefresh = (0.. Date: Sun, 26 Jan 2020 14:13:14 -0600 Subject: [PATCH 14/18] Avoid crash on HKSampleQuery error --- Loop/Managers/SleepStore.swift | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/Loop/Managers/SleepStore.swift b/Loop/Managers/SleepStore.swift index 571c9b5d10..1f94eb0a6d 100644 --- a/Loop/Managers/SleepStore.swift +++ b/Loop/Managers/SleepStore.swift @@ -8,6 +8,7 @@ import Foundation import HealthKit +import os.log enum SleepStoreResult { case success(T) @@ -18,11 +19,14 @@ enum SleepStoreError: Error { case noMatchingBedtime case unknownReturnConfiguration case noSleepDataAvailable + case queryError(Error) } class SleepStore { var healthStore: HKHealthStore + private let log = OSLog(category: "SleepStore") + public init( healthStore: HKHealthStore ) { @@ -68,7 +72,8 @@ class SleepStore { let query = HKSampleQuery(sampleType: sleepType, predicate: predicate, limit: sampleLimit, sortDescriptors: [sortByDate]) { (query, samples, error) in if let error = error { - completion(.failure(error as! SleepStoreError)) + self.log.error("Error fetching sleep data: %{public}@", String(describing: error)) + completion(.failure(.queryError(error))) } else if let samples = samples as? [HKCategorySample] { guard !samples.isEmpty else { completion(.failure(SleepStoreError.noSleepDataAvailable)) From 20bc003a503adb4bb071069296f627e7a0bec394 Mon Sep 17 00:00:00 2001 From: AQ Date: Sun, 26 Jan 2020 13:04:17 -0800 Subject: [PATCH 15/18] Fix crash due to incorrect error type --- Loop/Managers/SleepStore.swift | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Loop/Managers/SleepStore.swift b/Loop/Managers/SleepStore.swift index 571c9b5d10..afb26edcf9 100644 --- a/Loop/Managers/SleepStore.swift +++ b/Loop/Managers/SleepStore.swift @@ -18,6 +18,7 @@ enum SleepStoreError: Error { case noMatchingBedtime case unknownReturnConfiguration case noSleepDataAvailable + case queryError(String) // String is description of error } class SleepStore { @@ -68,7 +69,7 @@ class SleepStore { let query = HKSampleQuery(sampleType: sleepType, predicate: predicate, limit: sampleLimit, sortDescriptors: [sortByDate]) { (query, samples, error) in if let error = error { - completion(.failure(error as! SleepStoreError)) + completion(.failure(SleepStoreError.queryError(error.localizedDescription))) } else if let samples = samples as? [HKCategorySample] { guard !samples.isEmpty else { completion(.failure(SleepStoreError.noSleepDataAvailable)) From aeb14bef16acc3ce6049256c3ebe1ee24018fa74 Mon Sep 17 00:00:00 2001 From: AQ Date: Mon, 27 Jan 2020 15:44:38 -0800 Subject: [PATCH 16/18] Fix for authorization error --- Loop/Managers/LoopDataManager.swift | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/Loop/Managers/LoopDataManager.swift b/Loop/Managers/LoopDataManager.swift index 0864673c3f..157636368e 100644 --- a/Loop/Managers/LoopDataManager.swift +++ b/Loop/Managers/LoopDataManager.swift @@ -439,18 +439,28 @@ extension LoopDataManager { ].compactMap { $0 }) } + var sleepDataAuthorizationRequired: Bool { + return carbStore.healthStore.authorizationStatus(for: HKObjectType.categoryType(forIdentifier: HKCategoryTypeIdentifier.sleepAnalysis)!) == .notDetermined + } + + var sleepDataSharingDenied: Bool { + return carbStore.healthStore.authorizationStatus(for: HKObjectType.categoryType(forIdentifier: HKCategoryTypeIdentifier.sleepAnalysis)!) == .sharingDenied + } + /// True if any stores require HealthKit authorization var authorizationRequired: Bool { return glucoseStore.authorizationRequired || carbStore.authorizationRequired || - doseStore.authorizationRequired + doseStore.authorizationRequired || + sleepDataAuthorizationRequired } /// True if the user has explicitly denied access to any stores' HealthKit types private var sharingDenied: Bool { return glucoseStore.sharingDenied || carbStore.sharingDenied || - doseStore.sharingDenied + doseStore.sharingDenied || + sleepDataSharingDenied } func authorize(_ completion: @escaping () -> Void) { From ffbaea94c1b4a60b5d7e5283b75dfbdf51d91243 Mon Sep 17 00:00:00 2001 From: AQ Date: Mon, 27 Jan 2020 19:52:45 -0800 Subject: [PATCH 17/18] Remove delay to mirror LoopKit --- LoopCore/Insulin/ExponentialInsulinModelPreset.swift | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/LoopCore/Insulin/ExponentialInsulinModelPreset.swift b/LoopCore/Insulin/ExponentialInsulinModelPreset.swift index e65cdd1fb7..30b8a2c948 100644 --- a/LoopCore/Insulin/ExponentialInsulinModelPreset.swift +++ b/LoopCore/Insulin/ExponentialInsulinModelPreset.swift @@ -51,7 +51,7 @@ extension ExponentialInsulinModelPreset { } var model: InsulinModel { - return ExponentialInsulinModel(actionDuration: actionDuration, peakActivityTime: peakActivity, delay: effectDelay) + return ExponentialInsulinModel(actionDuration: actionDuration, peakActivityTime: peakActivity) } } @@ -60,10 +60,6 @@ extension ExponentialInsulinModelPreset: InsulinModel { public var effectDuration: TimeInterval { return model.effectDuration } - - public var delay: TimeInterval { - return model.delay - } public func percentEffectRemaining(at time: TimeInterval) -> Double { return model.percentEffectRemaining(at: time) From cc0d636ddc35175120eafc63e2a7e26041295fc3 Mon Sep 17 00:00:00 2001 From: Anna Quinlan <31571514+novalegra@users.noreply.github.com> Date: Mon, 27 Jan 2020 20:54:56 -0800 Subject: [PATCH 18/18] Update ExponentialInsulinModelPreset.swift --- LoopCore/Insulin/ExponentialInsulinModelPreset.swift | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/LoopCore/Insulin/ExponentialInsulinModelPreset.swift b/LoopCore/Insulin/ExponentialInsulinModelPreset.swift index 30b8a2c948..e65cdd1fb7 100644 --- a/LoopCore/Insulin/ExponentialInsulinModelPreset.swift +++ b/LoopCore/Insulin/ExponentialInsulinModelPreset.swift @@ -51,7 +51,7 @@ extension ExponentialInsulinModelPreset { } var model: InsulinModel { - return ExponentialInsulinModel(actionDuration: actionDuration, peakActivityTime: peakActivity) + return ExponentialInsulinModel(actionDuration: actionDuration, peakActivityTime: peakActivity, delay: effectDelay) } } @@ -60,6 +60,10 @@ extension ExponentialInsulinModelPreset: InsulinModel { public var effectDuration: TimeInterval { return model.effectDuration } + + public var delay: TimeInterval { + return model.delay + } public func percentEffectRemaining(at time: TimeInterval) -> Double { return model.percentEffectRemaining(at: time)