Skip to content

Commit

Permalink
Incorporate sleep data into complication user info transfer calculati…
Browse files Browse the repository at this point in the history
…ons (LoopKit#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]>
  • Loading branch information
novalegra and ps2 authored Feb 4, 2020
1 parent b6c1573 commit 05af23d
Show file tree
Hide file tree
Showing 6 changed files with 250 additions and 17 deletions.
4 changes: 4 additions & 0 deletions Loop.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -389,6 +389,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 */
Expand Down Expand Up @@ -1097,6 +1098,7 @@
C1F8B1DB223862D500DD66CF /* BolusProgressTableViewCell.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = BolusProgressTableViewCell.xib; sourceTree = "<group>"; };
C1FB428B217806A300FAB378 /* StateColorPalette.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = StateColorPalette.swift; sourceTree = "<group>"; };
C1FB428E217921D600FAB378 /* PumpManagerUI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PumpManagerUI.swift; sourceTree = "<group>"; };
E9BB27AA23B85C3500FB4987 /* SleepStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SleepStore.swift; sourceTree = "<group>"; };
/* End PBXFileReference section */

/* Begin PBXFrameworksBuildPhase section */
Expand Down Expand Up @@ -1649,6 +1651,7 @@
4F70C20F1DE8FAC5006380B7 /* StatusExtensionDataManager.swift */,
89ADE13A226BFA0F0067222B /* TestingScenariosManager.swift */,
4328E0341CFC0AE100E199AA /* WatchDataManager.swift */,
E9BB27AA23B85C3500FB4987 /* SleepStore.swift */,
);
path = Managers;
sourceTree = "<group>";
Expand Down Expand Up @@ -2626,6 +2629,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 */,
89D6953E23B6DF8A002B3066 /* PotentialCarbEntryTableViewCell.swift in Sources */,
89CA2B30226C0161004D9350 /* DirectoryObserver.swift in Sources */,
Expand Down
2 changes: 1 addition & 1 deletion Loop/Base.lproj/InfoPlist.strings
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,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.";
Expand Down
30 changes: 25 additions & 5 deletions Loop/Managers/LoopDataManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -440,32 +440,52 @@ extension LoopDataManager {
}
}

/// All the HealthKit types to be read and shared by stores
private var sampleTypes: Set<HKSampleType> {
/// All the HealthKit types to be read by stores
private var readTypes: Set<HKSampleType> {
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<HKSampleType> {
return Set([
glucoseStore.sampleType,
carbStore.sampleType,
doseStore.sampleType,
].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) {
// 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 })
Expand Down
120 changes: 120 additions & 0 deletions Loop/Managers/SleepStore.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
//
// SleepStore.swift
// Loop
//
// Created by Anna Quinlan on 12/28/19.
// Copyright © 2019 LoopKit Authors. All rights reserved.
//

import Foundation
import HealthKit
import os.log

enum SleepStoreResult<T> {
case success(T)
case failure(SleepStoreError)
}

enum SleepStoreError: Error {
case noMatchingBedtime
case unknownReturnConfiguration
case noSleepDataAvailable
case queryError(String) // String is description of error
}

class SleepStore {
var healthStore: HKHealthStore

private let log = OSLog(category: "SleepStore")

public init(
healthStore: HKHealthStore
) {
self.healthStore = healthStore
}

func getAverageSleepStartTime(sampleLimit: Int = 30, _ 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: 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 {
self.log.error("Error fetching sleep data: %{public}@", String(describing: error))
completion(.failure(SleepStoreError.queryError(error.localizedDescription)))
} 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, {
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

// 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(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!

return dateSeconds
}
}
83 changes: 72 additions & 11 deletions Loop/Managers/WatchDataManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -12,13 +12,15 @@ import WatchConnectivity
import LoopKit
import LoopCore


final class WatchDataManager: NSObject {

unowned let deviceManager: DeviceDataManager

init(deviceManager: DeviceDataManager) {
self.deviceManager = deviceManager
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")

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

private var lastSentSettings: LoopSettings?

let sleepStore: SleepStore

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)

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
case .failure:
self.bedtime = nil
}
}
}

@objc private func updateWatch(_ notification: Notification) {
guard
let rawUpdateContext = notification.userInfo?[LoopDataManager.LoopUpdateContextKey] as? LoopDataManager.LoopUpdateContext.RawValue,
Expand Down Expand Up @@ -113,12 +162,13 @@ final class WatchDataManager: NSObject {
}

let complicationShouldUpdate: Bool
updateBedtimeIfNeeded()

if let lastContext = lastComplicationContext,
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
Expand Down Expand Up @@ -322,6 +372,9 @@ 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"
]

if let session = watchSession {
Expand All @@ -334,8 +387,8 @@ extension WatchDataManager {

return items.joined(separator: "\n")
}
}

}

extension WCSession {
open override var debugDescription: String {
Expand All @@ -350,21 +403,29 @@ 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: Date?) -> 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)
// 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)
// if bedtime is before the current time or more than 24 hours away, use midnight instead
timeUntilRefresh = (0..<TimeInterval(hours: 24)).contains(timeUntilBedtime) ? 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 {
timeUntilMidnight = .hours(24)
timeUntilRefresh = .hours(24)
}

return timeUntilMidnight / Double(remainingComplicationUserInfoTransfers + 1)
return timeUntilRefresh / Double(remainingComplicationUserInfoTransfers + 1)
}
}
Loading

0 comments on commit 05af23d

Please sign in to comment.