diff --git a/Configuration/Entitlements/App-ios.entitlements b/Configuration/Entitlements/App-ios.entitlements index 153010f740..0c5960707b 100644 --- a/Configuration/Entitlements/App-ios.entitlements +++ b/Configuration/Entitlements/App-ios.entitlements @@ -10,6 +10,8 @@ applinks:*.home-assistant.io applinks:my.home-assistant.io + com.apple.developer.healthkit + com.apple.developer.networking.wifi-info com.apple.developer.nfc.readersession.formats diff --git a/HomeAssistant.xcodeproj/project.pbxproj b/HomeAssistant.xcodeproj/project.pbxproj index 8746a94272..b4911a7df5 100644 --- a/HomeAssistant.xcodeproj/project.pbxproj +++ b/HomeAssistant.xcodeproj/project.pbxproj @@ -944,6 +944,10 @@ 42AAEE882EC4B9880049E1F3 /* URLOpener.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42AAEE862EC4B7CC0049E1F3 /* URLOpener.swift */; }; 42AAEE8A2EC4C57F0049E1F3 /* ConnectionURLViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42AAEE892EC4C57F0049E1F3 /* ConnectionURLViewModel.swift */; }; 42ABB0BB2C888BB10081461D /* CarPlayConfigurationViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42ABB0BA2C888BB10081461D /* CarPlayConfigurationViewModel.swift */; }; + 42ABF0012FE3000000A1B2C3 /* HealthKitSensor.test.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42ABF0002FE3000000A1B2C3 /* HealthKitSensor.test.swift */; }; + 42ABF0032FE3000000A1B2C3 /* HealthKitSensor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42ABF0022FE3000000A1B2C3 /* HealthKitSensor.swift */; }; + 42ABF0042FE3000000A1B2C3 /* HealthKitSensor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42ABF0022FE3000000A1B2C3 /* HealthKitSensor.swift */; }; + 42ABF0052FE3000000A1B2C3 /* SensorListViewModelHealthKitTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42ABF0062FE3000000A1B2C3 /* SensorListViewModelHealthKitTests.swift */; }; 42AC20012FA1000000000001 /* PerformActionAppIntent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42AC10012FA1000000000001 /* PerformActionAppIntent.swift */; }; 42AC20012FA1000000000002 /* AssistPromptAppIntent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42AC10012FA1000000000002 /* AssistPromptAppIntent.swift */; }; 42AC20012FA1000000000003 /* GetCameraSnapshotAppIntent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42AC10012FA1000000000003 /* GetCameraSnapshotAppIntent.swift */; }; @@ -2880,6 +2884,9 @@ 42AAEE892EC4C57F0049E1F3 /* ConnectionURLViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConnectionURLViewModel.swift; sourceTree = ""; }; 42ABB0B82C888AA10081461D /* CarPlayConfig.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CarPlayConfig.swift; sourceTree = ""; }; 42ABB0BA2C888BB10081461D /* CarPlayConfigurationViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CarPlayConfigurationViewModel.swift; sourceTree = ""; }; + 42ABF0002FE3000000A1B2C3 /* HealthKitSensor.test.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HealthKitSensor.test.swift; sourceTree = ""; }; + 42ABF0022FE3000000A1B2C3 /* HealthKitSensor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HealthKitSensor.swift; sourceTree = ""; }; + 42ABF0062FE3000000A1B2C3 /* SensorListViewModelHealthKitTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SensorListViewModelHealthKitTests.swift; sourceTree = ""; }; 42AC10012FA1000000000001 /* PerformActionAppIntent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PerformActionAppIntent.swift; sourceTree = ""; }; 42AC10012FA1000000000002 /* AssistPromptAppIntent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AssistPromptAppIntent.swift; sourceTree = ""; }; 42AC10012FA1000000000003 /* GetCameraSnapshotAppIntent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GetCameraSnapshotAppIntent.swift; sourceTree = ""; }; @@ -4321,6 +4328,7 @@ B613936824F728F8002B8C5D /* InputOutputDeviceSensor.swift */, 11358AEB24FC9F300074C4E2 /* ActiveSensor.swift */, 110ED56225A563D600489AF7 /* DisplaySensor.swift */, + 42ABF0022FE3000000A1B2C3 /* HealthKitSensor.swift */, 118BDA8725A6DBBA00731016 /* FrontmostAppSensor.swift */, 42D3E49B2C5BB88F00444BE6 /* WatchBatterySensor.swift */, 42DEDA992C5B926400E9D29D /* AppVersionSensor.swift */, @@ -4349,6 +4357,7 @@ 11BC9E5624FDC1C900B9FBF7 /* ActiveSensor.test.swift */, 110ED57F25A570F100489AF7 /* DisplaySensor.test.swift */, 115AD72C267C57DA0090B243 /* FocusSensor.test.swift */, + 42ABF0002FE3000000A1B2C3 /* HealthKitSensor.test.swift */, ); path = Sensors; sourceTree = ""; @@ -6066,6 +6075,7 @@ isa = PBXGroup; children = ( 42B980DB2DC256A300BC5C08 /* SensorRow.test.swift */, + 42ABF0062FE3000000A1B2C3 /* SensorListViewModelHealthKitTests.swift */, 42A42A692DC37311000ABBAF /* PrivacyView.test.swift */, 42A42A6B2DC37F09000ABBAF /* AboutView.test.swift */, 42F8D8672DC3B0500022DE43 /* GesturesSetupView.test.swift */, @@ -8176,6 +8186,9 @@ com.apple.BackgroundModes = { enabled = 1; }; + com.apple.HealthKit = { + enabled = 1; + }; com.apple.HomeKit = { enabled = 0; }; @@ -9921,6 +9934,7 @@ 42A818E02BBEA8150083D045 /* AssistViewModel.test.swift in Sources */, 42A7F1212FBC000100BEEF01 /* NFCTagApproval.test.swift in Sources */, 42A42A6A2DC37311000ABBAF /* PrivacyView.test.swift in Sources */, + 42ABF0052FE3000000A1B2C3 /* SensorListViewModelHealthKitTests.swift in Sources */, 42FCD0082B9B1ECE0057783F /* SimulatorThreadClientService.swift in Sources */, 42A47A872C452D5400C9B43D /* WebViewExternalMessageHandlerTests.swift in Sources */, 422E626C2CDCF00A00987BD0 /* AreasService.test.swift in Sources */, @@ -10008,6 +10022,7 @@ B67CE87A22200F220034C1D0 /* LocationTrigger.swift in Sources */, B672333F225DB68B0031D629 /* WebSocketMessage.swift in Sources */, 11AF4D1A249C8253006C74C0 /* PedometerSensor.swift in Sources */, + 42ABF0042FE3000000A1B2C3 /* HealthKitSensor.swift in Sources */, 1117FB4D250C5F7C00895C13 /* DeviceBattery.swift in Sources */, 429C33C32F17A7010033EF5E /* HAUsagePredictionCommonControl.swift in Sources */, 42070EEC2BAC517A0031E96F /* AssistInAppIntentHandler.swift in Sources */, @@ -10417,6 +10432,7 @@ 42FF5E982E22E5C100BDF5EF /* TodoListItem.swift in Sources */, 11C4628824B109C100031902 /* WebhookResponseLocation.swift in Sources */, 11AF4D19249C8253006C74C0 /* PedometerSensor.swift in Sources */, + 42ABF0032FE3000000A1B2C3 /* HealthKitSensor.swift in Sources */, 42991E5B2FF3359D00C2C76B /* LiveActivityPendingStart.swift in Sources */, B6872E632226841400C475D1 /* MobileAppRegistrationRequest.swift in Sources */, 42145A622DAFD49A00891E04 /* HAEntity+CarPlay.swift in Sources */, @@ -10633,6 +10649,7 @@ 11AF4D2E249DA5AF006C74C0 /* GeocoderSensor.test.swift in Sources */, 114FACAE24B2ABA2006C581F /* Promise+WebhookJson.test.swift in Sources */, 115AD72D267C57DA0090B243 /* FocusSensor.test.swift in Sources */, + 42ABF0012FE3000000A1B2C3 /* HealthKitSensor.test.swift in Sources */, 11CB98C6249DE15B00B05222 /* LastUpdateSensor.test.swift in Sources */, D03D894D20E0BC2700D4F28D /* ClientEventTests.swift in Sources */, B7104E0100000000000000C2 /* NotificationHistoryStore.test.swift in Sources */, diff --git a/Sources/App/Resources/Info.plist b/Sources/App/Resources/Info.plist index c667170e85..07229cdd28 100644 --- a/Sources/App/Resources/Info.plist +++ b/Sources/App/Resources/Info.plist @@ -96,6 +96,8 @@ Authenticate to access kiosk mode settings. NSFocusStatusUsageDescription Report your focus status as a sensor. + NSHealthShareUsageDescription + Read selected Apple Health metrics so they can be shared with Home Assistant as sensors. NSLocalNetworkUsageDescription Locate and communicate with your Home Assistant instance. NSLocationAlwaysAndWhenInUseUsageDescription diff --git a/Sources/App/Resources/en.lproj/InfoPlist.strings b/Sources/App/Resources/en.lproj/InfoPlist.strings index 741d2b2e14..c8da7bf0f5 100644 --- a/Sources/App/Resources/en.lproj/InfoPlist.strings +++ b/Sources/App/Resources/en.lproj/InfoPlist.strings @@ -2,6 +2,7 @@ "NSCameraUsageDescription" = "Take photos and send them to your Home Assistant server."; "NSCrossWebsiteTrackingUsageDescription" = "Optionally enable cross-website tracking if your configuration requires it."; "NSFocusStatusUsageDescription" = "Report your focus status as a sensor."; +"NSHealthShareUsageDescription" = "Read selected Apple Health metrics so they can be shared with Home Assistant as sensors."; "NSLocalNetworkUsageDescription" = "Locate and communicate with your Home Assistant instance."; "NSLocationAlwaysAndWhenInUseUsageDescription" = "\n 🔵 We also need permission to access location 'Always' so the App can perform background operations. \n\n ⚠️ Without that, the App is unable to decide which connection (local or remote) to use in background and will use remote always."; "NSLocationAlwaysUsageDescription" = "We always need access to your location for features like iBeacons, geofences, background location updates and accurate reporting."; @@ -14,4 +15,4 @@ "NSSiriUsageDescription" = "We use Siri to allow created shortcuts to interact with the app."; "NSSpeechRecognitionUsageDescription" = "Used to dictate text to Assist."; "SEND_LOCATION_APP_SHORTCUT_TITLE" = "Send Location"; -"TemporaryFullAccuracyReasonManualUpdate" = "Grant full accuracy to use your current location for your device tracker."; \ No newline at end of file +"TemporaryFullAccuracyReasonManualUpdate" = "Grant full accuracy to use your current location for your device tracker."; diff --git a/Sources/App/Resources/en.lproj/Localizable.strings b/Sources/App/Resources/en.lproj/Localizable.strings index 3d0329abd9..b22ba44f0e 100644 --- a/Sources/App/Resources/en.lproj/Localizable.strings +++ b/Sources/App/Resources/en.lproj/Localizable.strings @@ -1430,6 +1430,14 @@ Home Assistant is open source, advocates for privacy and runs locally in your ho "settings_sensors.detail.state" = "State"; "settings_sensors.disabled_state_replacement" = "Disabled"; "settings_sensors.focus_permission.title" = "Focus Permission"; +"settings_sensors.health.error.authorization_failed" = "Apple Health authorization was not granted."; +"settings_sensors.health.error.unavailable" = "Apple Health is not available on this device."; +"settings_sensors.health.footer" = "Apple Health sensors use the existing per-sensor controls below. Health data is read only during normal sensor updates."; +"settings_sensors.health.header" = "Apple Health"; +"settings_sensors.health.request_access" = "Request Apple Health Access"; +"settings_sensors.health.status" = "Health Data"; +"settings_sensors.health.status.available" = "Available"; +"settings_sensors.health.status.unavailable" = "Unavailable"; "settings_sensors.last_updated.footer" = "Last Updated %@"; "settings_sensors.last_updated.prefix" = "Last Updated"; "settings_sensors.loading_error.title" = "Failed to load sensors"; diff --git a/Sources/App/Settings/Sensors/List/SensorListView.swift b/Sources/App/Settings/Sensors/List/SensorListView.swift index 96fa311091..9fc756e6ff 100644 --- a/Sources/App/Settings/Sensors/List/SensorListView.swift +++ b/Sources/App/Settings/Sensors/List/SensorListView.swift @@ -34,6 +34,7 @@ struct SensorListView: View { subtitle: L10n.SettingsSensors.body ) periodicUpdaterRow + healthKitSection motionFocusPermissionNeededView sensorsList } @@ -99,6 +100,39 @@ struct SensorListView: View { } } + private var healthKitSection: some View { + Section { + Button(action: { + viewModel.requestHealthAuthorization().done { [viewModel] in + DispatchQueue.main.async { + viewModel.refresh() + } + }.catch { [viewModel] error in + DispatchQueue.main.async { + viewModel.alertMessage = error.localizedDescription + viewModel.showAlert = true + } + } + }) { + Text(L10n.SettingsSensors.Health.requestAccess) + } + .disabled(viewModel.healthKitStatus == .unavailable) + + if let healthKitStatus = viewModel.healthKitStatus { + HStack { + Text(L10n.SettingsSensors.Health.status) + Spacer() + Text(healthStatusDescription(healthKitStatus)) + .foregroundColor(.secondary) + } + } + } header: { + Text(L10n.SettingsSensors.Health.header) + } footer: { + Text(L10n.SettingsSensors.Health.footer) + } + } + @ViewBuilder private var motionFocusPermissionNeededView: some View { if viewModel.motionAuthorizationStatus != nil || viewModel.focusAuthorizationStatus != nil { @@ -183,4 +217,13 @@ struct SensorListView: View { return L10n.SettingsDetails.Location.FocusPermission.needsRequest } } + + private func healthStatusDescription(_ status: HealthKitSensor.AuthorizationStatus) -> String { + switch status { + case .unavailable: + return L10n.SettingsSensors.Health.Status.unavailable + case .available: + return L10n.SettingsSensors.Health.Status.available + } + } } diff --git a/Sources/App/Settings/Sensors/List/SensorListViewModel.swift b/Sources/App/Settings/Sensors/List/SensorListViewModel.swift index 724af45534..dade7dc9b8 100644 --- a/Sources/App/Settings/Sensors/List/SensorListViewModel.swift +++ b/Sources/App/Settings/Sensors/List/SensorListViewModel.swift @@ -10,6 +10,7 @@ class SensorListViewModel: ObservableObject { @Published var lastUpdateDate: Date? @Published var motionAuthorizationStatus: CMAuthorizationStatus? @Published var focusAuthorizationStatus: FocusStatusWrapper.AuthorizationStatus? + @Published var healthKitStatus: HealthKitSensor.AuthorizationStatus? @Published var periodicUpdateInterval: TimeInterval? = Current.settingsStore.periodicUpdateInterval @Published var alertMessage: String? @Published var showAlert: Bool = false @@ -28,6 +29,8 @@ class SensorListViewModel: ObservableObject { } func updatePermissions() { + healthKitStatus = Current.healthKit.authorizationStatus() + if Current.motion.isActivityAvailable() { motionAuthorizationStatus = CMMotionActivityManager.authorizationStatus() } else { @@ -60,6 +63,12 @@ class SensorListViewModel: ObservableObject { Current.settingsStore.periodicUpdateInterval = interval } + func requestHealthAuthorization() -> Promise { + Current.healthKit.requestReadAuthorization().get { [weak self] in + self?.healthKitStatus = Current.healthKit.authorizationStatus() + } + } + // MARK: - Permissions Handling func requestMotionAuthorization(completion: @escaping () -> Void) { diff --git a/Sources/Shared/API/Webhook/Sensors/HealthKitSensor.swift b/Sources/Shared/API/Webhook/Sensors/HealthKitSensor.swift new file mode 100644 index 0000000000..6d8161c3b7 --- /dev/null +++ b/Sources/Shared/API/Webhook/Sensors/HealthKitSensor.swift @@ -0,0 +1,154 @@ +import Foundation +import PromiseKit + +public final class HealthKitSensor: SensorProvider { + enum HealthKitSensorError: LocalizedError { + case authorizationFailed + case unavailable + + var errorDescription: String? { + switch self { + case .authorizationFailed: + return L10n.SettingsSensors.Health.Error.authorizationFailed + case .unavailable: + return L10n.SettingsSensors.Health.Error.unavailable + } + } + } + + public enum AuthorizationStatus: Equatable { + case unavailable + case available + } + + public enum Metric: CaseIterable, Codable { + case steps + case restingHeartRate + + public var uniqueID: String { + switch self { + case .steps: return "health_steps" + case .restingHeartRate: return "health_resting_heart_rate" + } + } + + public var name: String { + switch self { + case .steps: return "Health Steps" + case .restingHeartRate: return "Resting Heart Rate" + } + } + + public var icon: String { + switch self { + case .steps: return "mdi:walk" + case .restingHeartRate: return "mdi:heart-pulse" + } + } + + public var unit: String { + switch self { + case .steps: return "steps" + case .restingHeartRate: return "bpm" + } + } + } + + public let request: SensorProviderRequest + + public init(request: SensorProviderRequest) { + self.request = request + } + + public static func isHealthSensor(uniqueID: String?) -> Bool { + guard let uniqueID else { return false } + return Metric.allCases.contains { $0.uniqueID == uniqueID } + } + + public func sensors() -> Promise<[WebhookSensor]> { + guard Current.healthKit.isAvailable() else { + return .value(Self.unavailableSensors()) + } + + let start = Current.calendar().startOfDay(for: Current.date()) + let end = Current.date() + let restingHeartRateStart = Current.calendar().date(byAdding: .day, value: -7, to: end) ?? start + + return firstly { () -> Guarantee<[Result]> in + when(resolved: Metric.allCases.map { metric in + value(for: metric, start: start, end: end, restingHeartRateStart: restingHeartRateStart) + }) + }.map { results -> [HealthSensorValue] in + let values = results.compactMap { result -> HealthSensorValue? in + if case let .fulfilled(value) = result { + return value + } else { + return nil + } + } + + return values + }.map(Self.sensors(from:)) + } + + private func value( + for metric: Metric, + start: Date, + end: Date, + restingHeartRateStart: Date + ) -> Promise { + guard Current.sensors.isEnabled(uniqueID: metric.uniqueID) else { + return .value(nil) + } + + switch metric { + case .steps: + return Current.healthKit.queryStepCount(start, end).map { + HealthSensorValue(metric: metric, value: $0.map(Double.init)) + } + case .restingHeartRate: + return Current.healthKit.queryLatestRestingHeartRate(restingHeartRateStart, end).map { + HealthSensorValue(metric: metric, value: $0) + } + } + } + + private static func sensors(from values: [HealthSensorValue]) -> [WebhookSensor] { + Metric.allCases.map { metric in + let value = values.first(where: { $0.metric == metric })?.value + return sensor(metric: metric, value: value) + } + } + + private static func unavailableSensors() -> [WebhookSensor] { + Metric.allCases.map { sensor(metric: $0, value: nil) } + } + + private static func sensor(metric: Metric, value: Double?) -> WebhookSensor { + let state: Any + switch metric { + case .steps: + state = value.map { Int($0) } ?? "unavailable" + case .restingHeartRate: + state = value ?? "unavailable" + } + + return WebhookSensor( + name: metric.name, + uniqueID: metric.uniqueID, + icon: metric.icon, + state: state, + unit: metric.unit + ) + } +} + +public struct HealthSensorValue: Codable, Equatable { + public let metric: HealthKitSensor.Metric + public let value: Double? + + public init(metric: HealthKitSensor.Metric, value: Double?) { + self.metric = metric + self.value = value + } +} diff --git a/Sources/Shared/Environment/Environment.swift b/Sources/Shared/Environment/Environment.swift index c5acad10d3..18163cb2bb 100644 --- a/Sources/Shared/Environment/Environment.swift +++ b/Sources/Shared/Environment/Environment.swift @@ -4,6 +4,9 @@ import CoreMotion import Foundation import GRDB import HAKit +#if canImport(HealthKit) +import HealthKit +#endif import NetworkExtension import PromiseKit import RealmSwift @@ -276,6 +279,7 @@ public class AppEnvironment { $0.register(provider: KioskModeSensor.self) $0.register(provider: KioskBrightnessSensor.self) $0.register(provider: KioskVolumeSensor.self) + $0.register(provider: HealthKitSensor.self) } public var localized = LocalizedManager() @@ -507,6 +511,117 @@ public class AppEnvironment { public var pedometer = Pedometer() + public struct HealthKit { + public var isAvailable: () -> Bool = { + #if canImport(HealthKit) && os(iOS) + guard !Current.isCatalyst, !Current.isAppExtension else { return false } + return HKHealthStore.isHealthDataAvailable() + #else + return false + #endif + } + + public var authorizationStatus: () -> HealthKitSensor.AuthorizationStatus = { + Current.healthKit.isAvailable() ? .available : .unavailable + } + + public var requestReadAuthorization: () -> Promise = { + #if canImport(HealthKit) && os(iOS) + guard Current.healthKit.isAvailable() else { + return .init(error: HealthKitSensor.HealthKitSensorError.unavailable) + } + + let healthStore = HKHealthStore() + let types = Set([ + HKObjectType.quantityType(forIdentifier: .stepCount), + HKObjectType.quantityType(forIdentifier: .restingHeartRate), + ].compactMap { $0 }) + + let (promise, seal) = Promise.pending() + healthStore.requestAuthorization(toShare: Set(), read: types) { success, error in + if let error { + seal.reject(error) + } else if success { + seal.fulfill(()) + } else { + seal.reject(HealthKitSensor.HealthKitSensorError.authorizationFailed) + } + } + return promise + #else + return .init(error: HealthKitSensor.HealthKitSensorError.unavailable) + #endif + } + + public var queryStepCount: (Date, Date) -> Promise = { start, end in + #if canImport(HealthKit) && os(iOS) + guard Current.healthKit.isAvailable() else { + return .value(nil) + } + + guard let quantityType = HKObjectType.quantityType(forIdentifier: .stepCount) else { + return .value(nil) + } + + let healthStore = HKHealthStore() + let predicate = HKQuery.predicateForSamples(withStart: start, end: end) + let (promise, seal) = Promise.pending() + let query = HKStatisticsQuery( + quantityType: quantityType, + quantitySamplePredicate: predicate, + options: .cumulativeSum + ) { _, statistics, error in + if let error { + seal.reject(error) + } else { + let steps = statistics?.sumQuantity()?.doubleValue(for: .count()) + seal.fulfill(steps.map(Int.init)) + } + } + healthStore.execute(query) + return promise + #else + return .value(nil) + #endif + } + + public var queryLatestRestingHeartRate: (Date, Date) -> Promise = { start, end in + #if canImport(HealthKit) && os(iOS) + guard Current.healthKit.isAvailable() else { + return .value(nil) + } + + guard let quantityType = HKObjectType.quantityType(forIdentifier: .restingHeartRate) else { + return .value(nil) + } + + let healthStore = HKHealthStore() + let predicate = HKQuery.predicateForSamples(withStart: start, end: end) + let sort = NSSortDescriptor(key: HKSampleSortIdentifierEndDate, ascending: false) + let (promise, seal) = Promise.pending() + let query = HKSampleQuery( + sampleType: quantityType, + predicate: predicate, + limit: 1, + sortDescriptors: [sort] + ) { _, samples, error in + if let error { + seal.reject(error) + } else { + let sample = samples?.first as? HKQuantitySample + seal.fulfill(sample?.quantity.doubleValue(for: .init(from: "count/min"))) + } + } + healthStore.execute(query) + return promise + #else + return .value(nil) + #endif + } + } + + public var healthKit = HealthKit() + /// Wrapper around CMAltimeter for barometric pressure readings public struct Barometer { private let underlyingAltimeter = CMAltimeter() diff --git a/Sources/Shared/Resources/Swiftgen/Strings.swift b/Sources/Shared/Resources/Swiftgen/Strings.swift index 34d9062e8b..2f7b0b18c0 100644 --- a/Sources/Shared/Resources/Swiftgen/Strings.swift +++ b/Sources/Shared/Resources/Swiftgen/Strings.swift @@ -4808,6 +4808,28 @@ public enum L10n { /// Focus Permission public static var title: String { return L10n.tr("Localizable", "settings_sensors.focus_permission.title") } } + public enum Health { + public enum Error { + /// Apple Health authorization was not granted. + public static var authorizationFailed: String { return L10n.tr("Localizable", "settings_sensors.health.error.authorization_failed") } + /// Apple Health is not available on this device. + public static var unavailable: String { return L10n.tr("Localizable", "settings_sensors.health.error.unavailable") } + } + /// Apple Health sensors use the existing per-sensor controls below. Health data is read only during normal sensor updates. + public static var footer: String { return L10n.tr("Localizable", "settings_sensors.health.footer") } + /// Apple Health + public static var header: String { return L10n.tr("Localizable", "settings_sensors.health.header") } + /// Request Apple Health Access + public static var requestAccess: String { return L10n.tr("Localizable", "settings_sensors.health.request_access") } + /// Health Data + public static var status: String { return L10n.tr("Localizable", "settings_sensors.health.status") } + public enum Status { + /// Available + public static var available: String { return L10n.tr("Localizable", "settings_sensors.health.status.available") } + /// Unavailable + public static var unavailable: String { return L10n.tr("Localizable", "settings_sensors.health.status.unavailable") } + } + } public enum LastUpdated { /// Last Updated %@ public static func footer(_ p1: Any) -> String { diff --git a/Tests/App/Settings/SensorListViewModelHealthKitTests.swift b/Tests/App/Settings/SensorListViewModelHealthKitTests.swift new file mode 100644 index 0000000000..eebee80ee9 --- /dev/null +++ b/Tests/App/Settings/SensorListViewModelHealthKitTests.swift @@ -0,0 +1,73 @@ +@testable import HomeAssistant +import PromiseKit +@testable import Shared +import XCTest + +class SensorListViewModelHealthKitTests: XCTestCase { + private var originalHealthKit: AppEnvironment.HealthKit! + private var previousDisabledSensors: Any? + + override func setUp() { + super.setUp() + + originalHealthKit = Current.healthKit + previousDisabledSensors = Current.settingsStore.prefs.object(forKey: "disabledSensors") + + Current.settingsStore.prefs.removeObject(forKey: "disabledSensors") + Current.healthKit.isAvailable = { true } + Current.healthKit.authorizationStatus = { .available } + } + + override func tearDown() { + restore(previousDisabledSensors, forKey: "disabledSensors") + Current.healthKit = originalHealthKit + originalHealthKit = nil + super.tearDown() + } + + private func restore(_ value: Any?, forKey key: String) { + if let value { + Current.settingsStore.prefs.set(value, forKey: key) + } else { + Current.settingsStore.prefs.removeObject(forKey: key) + } + } + + func testRequestHealthAuthorizationRefreshesHealthKitStatus() throws { + var requested = false + var status = HealthKitSensor.AuthorizationStatus.unavailable + Current.healthKit.authorizationStatus = { status } + Current.healthKit.requestReadAuthorization = { + requested = true + status = .available + return .value(()) + } + let viewModel = SensorListViewModel() + + try hang(viewModel.requestHealthAuthorization()) + + XCTAssertTrue(requested) + XCTAssertEqual(viewModel.healthKitStatus, .available) + } + + func testUpdatePermissionsUsesHealthKitStatus() { + Current.healthKit.authorizationStatus = { .unavailable } + let viewModel = SensorListViewModel() + + viewModel.updatePermissions() + + XCTAssertEqual(viewModel.healthKitStatus, .unavailable) + } + + func testUpdateAllSensorsIncludesHealthSensors() { + Current.sensors.setEnabled(false, forUniqueID: HealthKitSensor.Metric.steps.uniqueID) + let viewModel = SensorListViewModel() + viewModel.sensors = [ + WebhookSensor(name: "Health Steps", uniqueID: HealthKitSensor.Metric.steps.uniqueID), + ] + + viewModel.updateAllSensors(isEnabled: true) + + XCTAssertTrue(Current.sensors.isEnabled(uniqueID: HealthKitSensor.Metric.steps.uniqueID)) + } +} diff --git a/Tests/Shared/Sensors/HealthKitSensor.test.swift b/Tests/Shared/Sensors/HealthKitSensor.test.swift new file mode 100644 index 0000000000..1f5169d402 --- /dev/null +++ b/Tests/Shared/Sensors/HealthKitSensor.test.swift @@ -0,0 +1,166 @@ +import Foundation +import PromiseKit +@testable import Shared +import Version +import XCTest + +class HealthKitSensorTests: XCTestCase { + private var request: SensorProviderRequest! + private var stepQueryCount: Int! + private var restingHeartRateQueryCount: Int! + private var originalDate: (() -> Date)! + private var originalCalendar: (() -> Calendar)! + private var originalHealthKit: AppEnvironment.HealthKit! + private var previousDisabledSensors: Any? + + override func setUp() { + super.setUp() + + originalDate = Current.date + originalCalendar = Current.calendar + originalHealthKit = Current.healthKit + previousDisabledSensors = Current.settingsStore.prefs.object(forKey: "disabledSensors") + + request = .init( + reason: .trigger("unit-test"), + dependencies: .init(), + location: nil, + serverVersion: Version() + ) + + stepQueryCount = 0 + restingHeartRateQueryCount = 0 + Current.date = { Date(timeIntervalSince1970: 1_000_000) } + Current.calendar = { Calendar(identifier: .gregorian) } + Current.settingsStore.prefs.removeObject(forKey: "disabledSensors") + Current.sensors.setEnabled(true, forUniqueID: HealthKitSensor.Metric.steps.uniqueID) + Current.sensors.setEnabled(true, forUniqueID: HealthKitSensor.Metric.restingHeartRate.uniqueID) + Current.healthKit.isAvailable = { true } + Current.healthKit.queryStepCount = { [weak self] _, _ in + self?.stepQueryCount += 1 + return .value(1234) + } + Current.healthKit.queryLatestRestingHeartRate = { [weak self] _, _ in + self?.restingHeartRateQueryCount += 1 + return .value(62.4) + } + } + + override func tearDown() { + restore(previousDisabledSensors, forKey: "disabledSensors") + Current.date = originalDate + Current.calendar = originalCalendar + Current.healthKit = originalHealthKit + originalDate = nil + originalCalendar = nil + originalHealthKit = nil + super.tearDown() + } + + private func restore(_ value: Any?, forKey key: String) { + if let value { + Current.settingsStore.prefs.set(value, forKey: key) + } else { + Current.settingsStore.prefs.removeObject(forKey: key) + } + } + + func testUnavailableHealthKitReturnsUnavailableSensorsAndDoesNotQueryHealthKit() throws { + Current.healthKit.isAvailable = { false } + + let sensors = try hang(HealthKitSensor(request: request).sensors()) + + XCTAssertEqual( + sensors.first(where: { $0.UniqueID == HealthKitSensor.Metric.steps.uniqueID })?.State as? String, + "unavailable" + ) + XCTAssertEqual( + sensors.first(where: { $0.UniqueID == HealthKitSensor.Metric.restingHeartRate.uniqueID })?.State as? String, + "unavailable" + ) + XCTAssertEqual(stepQueryCount, 0) + XCTAssertEqual(restingHeartRateQueryCount, 0) + } + + func testSuccessfulDataMapsBothSensors() throws { + let sensors = try hang(HealthKitSensor(request: request).sensors()) + + let steps = try XCTUnwrap(sensors.first(where: { $0.UniqueID == HealthKitSensor.Metric.steps.uniqueID })) + XCTAssertEqual(steps.Name, "Health Steps") + XCTAssertEqual(steps.Icon, "mdi:walk") + XCTAssertEqual(steps.UnitOfMeasurement, "steps") + XCTAssertEqual(steps.State as? Int, 1234) + + let restingHeartRate = try XCTUnwrap(sensors.first( + where: { $0.UniqueID == HealthKitSensor.Metric.restingHeartRate.uniqueID } + )) + XCTAssertEqual(restingHeartRate.Name, "Resting Heart Rate") + XCTAssertEqual(restingHeartRate.Icon, "mdi:heart-pulse") + XCTAssertEqual(restingHeartRate.UnitOfMeasurement, "bpm") + XCTAssertEqual(restingHeartRate.State as? Double, 62.4) + } + + func testMissingDataReturnsUnavailableRows() throws { + Current.healthKit.queryStepCount = { [weak self] _, _ in + self?.stepQueryCount += 1 + return .value(nil) + } + Current.healthKit.queryLatestRestingHeartRate = { [weak self] _, _ in + self?.restingHeartRateQueryCount += 1 + return .value(nil) + } + + let sensors = try hang(HealthKitSensor(request: request).sensors()) + + XCTAssertEqual( + sensors.first(where: { $0.UniqueID == HealthKitSensor.Metric.steps.uniqueID })?.State as? String, + "unavailable" + ) + XCTAssertEqual( + sensors.first(where: { $0.UniqueID == HealthKitSensor.Metric.restingHeartRate.uniqueID })?.State as? String, + "unavailable" + ) + } + + func testDisabledIndividualSensorDoesNotQueryThatMetric() throws { + Current.sensors.setEnabled(false, forUniqueID: HealthKitSensor.Metric.restingHeartRate.uniqueID) + + let sensors = try hang(HealthKitSensor(request: request).sensors()) + + XCTAssertNotNil(sensors.first(where: { $0.UniqueID == HealthKitSensor.Metric.steps.uniqueID })) + XCTAssertEqual( + sensors.first(where: { $0.UniqueID == HealthKitSensor.Metric.restingHeartRate.uniqueID })?.State as? String, + "unavailable" + ) + XCTAssertEqual(stepQueryCount, 1) + XCTAssertEqual(restingHeartRateQueryCount, 0) + } + + func testReEnabledIndividualSensorQueriesThatMetric() throws { + Current.sensors.setEnabled(false, forUniqueID: HealthKitSensor.Metric.restingHeartRate.uniqueID) + _ = try hang(HealthKitSensor(request: request).sensors()) + stepQueryCount = 0 + restingHeartRateQueryCount = 0 + request.reason = .trigger(LocationUpdateTrigger.Periodic.rawValue) + Current.date = { Date(timeIntervalSince1970: 1_000_000 + 60) } + Current.sensors.setEnabled(true, forUniqueID: HealthKitSensor.Metric.restingHeartRate.uniqueID) + + _ = try hang(HealthKitSensor(request: request).sensors()) + + XCTAssertEqual(stepQueryCount, 1) + XCTAssertEqual(restingHeartRateQueryCount, 1) + } + + func testAutomaticUpdateQueriesHealthKit() throws { + _ = try hang(HealthKitSensor(request: request).sensors()) + stepQueryCount = 0 + restingHeartRateQueryCount = 0 + request.reason = .trigger(LocationUpdateTrigger.Periodic.rawValue) + Current.date = { Date(timeIntervalSince1970: 1_000_000 + 60) } + + _ = try hang(HealthKitSensor(request: request).sensors()) + + XCTAssertEqual(stepQueryCount, 1) + XCTAssertEqual(restingHeartRateQueryCount, 1) + } +}