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 830400d066..5d420239fe 100644 --- a/HomeAssistant.xcodeproj/project.pbxproj +++ b/HomeAssistant.xcodeproj/project.pbxproj @@ -3253,6 +3253,9 @@ com.apple.BackgroundModes = { enabled = 1; }; + com.apple.HealthKit = { + enabled = 1; + }; com.apple.HomeKit = { enabled = 0; }; diff --git a/Sources/App/Resources/Info.plist b/Sources/App/Resources/Info.plist index a980827e51..e0ac4ff5e9 100644 --- a/Sources/App/Resources/Info.plist +++ b/Sources/App/Resources/Info.plist @@ -86,6 +86,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 0022def007..cd4b9c45a3 100644 --- a/Sources/App/Resources/en.lproj/Localizable.strings +++ b/Sources/App/Resources/en.lproj/Localizable.strings @@ -1697,6 +1697,13 @@ 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.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"; @@ -2424,4 +2431,4 @@ While no URL is considered safe, this server's data won't sync to the watch and "widgets.todo_list.select_list" = "Edit widget to select list."; "widgets.todo_list.title" = "To-do List"; "yaml_preview.share" = "Share Contents"; -"yes_label" = "Yes"; \ No newline at end of file +"yes_label" = "Yes"; diff --git a/Sources/App/Settings/Sensors/List/SensorListView.swift b/Sources/App/Settings/Sensors/List/SensorListView.swift index 781a2476cb..a21c591aa1 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,36 @@ struct SensorListView: View { } } + private var healthKitSection: some View { + Section { + Button(action: { + Task { @MainActor [viewModel] in + do { + try await viewModel.requestHealthAuthorization() + viewModel.refresh() + } catch { + viewModel.alertMessage = error.localizedDescription + viewModel.showAlert = true + } + } + }) { + Text(L10n.SettingsSensors.Health.requestAccess) + } + .disabled(!viewModel.isHealthKitAvailable) + + HStack { + Text(L10n.SettingsSensors.Health.status) + Spacer() + Text(healthStatusDescription(isAvailable: viewModel.isHealthKitAvailable)) + .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,6 +214,12 @@ struct SensorListView: View { return L10n.SettingsDetails.Location.FocusPermission.needsRequest } } + + private func healthStatusDescription(isAvailable: Bool) -> String { + isAvailable + ? L10n.SettingsSensors.Health.Status.available + : L10n.SettingsSensors.Health.Status.unavailable + } } extension SensorListView: SettingsScreenSearchable { diff --git a/Sources/App/Settings/Sensors/List/SensorListViewModel.swift b/Sources/App/Settings/Sensors/List/SensorListViewModel.swift index 724af45534..f21426b866 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 isHealthKitAvailable = false @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() { + isHealthKitAvailable = Current.healthKitService.isAvailable() + if Current.motion.isActivityAvailable() { motionAuthorizationStatus = CMMotionActivityManager.authorizationStatus() } else { @@ -60,6 +63,12 @@ class SensorListViewModel: ObservableObject { Current.settingsStore.periodicUpdateInterval = interval } + @MainActor + func requestHealthAuthorization() async throws { + try await Current.healthKitService.requestReadAuthorization() + isHealthKitAvailable = Current.healthKitService.isAvailable() + } + // 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..78784627bb --- /dev/null +++ b/Sources/Shared/API/Webhook/Sensors/HealthKitSensor.swift @@ -0,0 +1,130 @@ +#if os(iOS) && !targetEnvironment(macCatalyst) +import Foundation +import PromiseKit + +public final class HealthKitSensor: SensorProvider { + 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.healthKitService.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 + let (promise, seal) = Promise<[WebhookSensor]>.pending() + + Task { + async let steps = value( + for: .steps, + start: start, + end: end, + restingHeartRateStart: restingHeartRateStart + ) + async let restingHeartRate = value( + for: .restingHeartRate, + start: start, + end: end, + restingHeartRateStart: restingHeartRateStart + ) + + let values = await [steps, restingHeartRate].compactMap { $0 } + seal.fulfill(Self.sensors(from: values)) + } + + return promise + } + + private func value( + for metric: Metric, + start: Date, + end: Date, + restingHeartRateStart: Date + ) async -> HealthSensorValue? { + guard Current.sensors.isEnabled(uniqueID: metric.uniqueID) else { + return nil + } + + switch metric { + case .steps: + let value = try? await Current.healthKitService.queryStepCount(start, end) + return HealthSensorValue(metric: metric, value: value.map(Double.init)) + case .restingHeartRate: + let value = try? await Current.healthKitService.queryLatestRestingHeartRate(restingHeartRateStart, end) + return HealthSensorValue(metric: metric, value: value) + } + } + + 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 + ) + } +} +#endif diff --git a/Sources/Shared/API/Webhook/Sensors/HealthKitService.swift b/Sources/Shared/API/Webhook/Sensors/HealthKitService.swift new file mode 100644 index 0000000000..9ef7bafa55 --- /dev/null +++ b/Sources/Shared/API/Webhook/Sensors/HealthKitService.swift @@ -0,0 +1,94 @@ +#if os(iOS) && !targetEnvironment(macCatalyst) +import Foundation +import HealthKit + +public struct HealthKitService { + public enum HealthKitServiceError: LocalizedError { + case unavailable + + public var errorDescription: String? { + switch self { + case .unavailable: + return L10n.SettingsSensors.Health.Error.unavailable + } + } + } + + private static let healthStore = HKHealthStore() + + public var isAvailable: () -> Bool = { + HKHealthStore.isHealthDataAvailable() && !Current.isAppExtension + } + + public var requestReadAuthorization: () async throws -> Void = { + guard HKHealthStore.isHealthDataAvailable(), !Current.isAppExtension else { + throw HealthKitServiceError.unavailable + } + + try await healthStore.requestAuthorization( + toShare: Set(), + read: healthDataTypes() + ) + } + + public var queryStepCount: (Date, Date) async throws -> Int? = { start, end in + guard HKHealthStore.isHealthDataAvailable(), !Current.isAppExtension, + let quantityType = HKObjectType.quantityType(forIdentifier: .stepCount) else { + return nil + } + + let predicate = HKQuery.predicateForSamples(withStart: start, end: end) + return try await withCheckedThrowingContinuation { continuation in + let query = HKStatisticsQuery( + quantityType: quantityType, + quantitySamplePredicate: predicate, + options: .cumulativeSum + ) { _, statistics, error in + if let error { + continuation.resume(throwing: error) + } else { + let steps = statistics?.sumQuantity()?.doubleValue(for: .count()) + continuation.resume(returning: steps.map(Int.init)) + } + } + healthStore.execute(query) + } + } + + public var queryLatestRestingHeartRate: (Date, Date) async throws -> Double? = { start, end in + guard HKHealthStore.isHealthDataAvailable(), !Current.isAppExtension, + let quantityType = HKObjectType.quantityType(forIdentifier: .restingHeartRate) else { + return nil + } + + let predicate = HKQuery.predicateForSamples(withStart: start, end: end) + let sort = NSSortDescriptor(key: HKSampleSortIdentifierEndDate, ascending: false) + return try await withCheckedThrowingContinuation { continuation in + let query = HKSampleQuery( + sampleType: quantityType, + predicate: predicate, + limit: 1, + sortDescriptors: [sort] + ) { _, samples, error in + if let error { + continuation.resume(throwing: error) + } else { + let sample = samples?.first as? HKQuantitySample + let unit = HKUnit.count().unitDivided(by: .minute()) + continuation.resume(returning: sample?.quantity.doubleValue(for: unit)) + } + } + healthStore.execute(query) + } + } + + public init() {} + + private static func healthDataTypes() -> Set { + Set([ + HKObjectType.quantityType(forIdentifier: .stepCount), + HKObjectType.quantityType(forIdentifier: .restingHeartRate), + ].compactMap { $0 }) + } +} +#endif diff --git a/Sources/Shared/API/Webhook/Sensors/HealthSensorValue.swift b/Sources/Shared/API/Webhook/Sensors/HealthSensorValue.swift new file mode 100644 index 0000000000..4ac9eaa0f9 --- /dev/null +++ b/Sources/Shared/API/Webhook/Sensors/HealthSensorValue.swift @@ -0,0 +1,13 @@ +#if os(iOS) && !targetEnvironment(macCatalyst) +import Foundation + +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 + } +} +#endif diff --git a/Sources/Shared/Environment/Environment.swift b/Sources/Shared/Environment/Environment.swift index 1e7d3d1c0a..20f8416606 100644 --- a/Sources/Shared/Environment/Environment.swift +++ b/Sources/Shared/Environment/Environment.swift @@ -380,6 +380,9 @@ public class AppEnvironment { $0.register(provider: KioskScreensaverSensor.self) $0.register(provider: CameraMotionSensor.self) $0.register(provider: CameraStreamSensor.self) + #if os(iOS) && !targetEnvironment(macCatalyst) + $0.register(provider: HealthKitSensor.self) + #endif } public var localized = LocalizedManager() @@ -618,6 +621,10 @@ public class AppEnvironment { public var pedometer = Pedometer() + #if os(iOS) && !targetEnvironment(macCatalyst) + public var healthKitService = HealthKitService() + #endif + /// 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 9f5724d89f..8d79e01ea0 100644 --- a/Sources/Shared/Resources/Swiftgen/Strings.swift +++ b/Sources/Shared/Resources/Swiftgen/Strings.swift @@ -5622,6 +5622,26 @@ public enum L10n { /// Focus Permission public static var title: String { return L10n.tr("Localizable", "settings_sensors.focus_permission.title") } } + public enum Health { + /// 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 Error { + /// Apple Health is not available on this device. + public static var unavailable: String { return L10n.tr("Localizable", "settings_sensors.health.error.unavailable") } + } + 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..3d34bb2d52 --- /dev/null +++ b/Tests/App/Settings/SensorListViewModelHealthKitTests.swift @@ -0,0 +1,81 @@ +@testable import HomeAssistant +import PromiseKit +@testable import Shared +import XCTest + +class SensorListViewModelHealthKitTests: XCTestCase { + private var originalHealthKitService: HealthKitService! + private var originalSensors: SensorContainer! + private var previousDisabledSensors: Any? + + override func setUp() { + super.setUp() + + originalHealthKitService = Current.healthKitService + originalSensors = Current.sensors + previousDisabledSensors = Current.settingsStore.prefs.object(forKey: "disabledSensors") + + Current.sensors = SensorContainer() + Current.settingsStore.prefs.removeObject(forKey: "disabledSensors") + Current.healthKitService.isAvailable = { true } + } + + override func tearDown() { + restore(previousDisabledSensors, forKey: "disabledSensors") + Current.healthKitService = originalHealthKitService + Current.sensors = originalSensors + originalHealthKitService = nil + originalSensors = 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) + } + } + + @MainActor + func testRequestHealthAuthorizationRefreshesHealthKitAvailability() async throws { + var requested = false + var isAvailable = false + Current.healthKitService.isAvailable = { isAvailable } + Current.healthKitService.requestReadAuthorization = { + requested = true + isAvailable = true + } + let viewModel = SensorListViewModel() + + try await viewModel.requestHealthAuthorization() + + XCTAssertTrue(requested) + XCTAssertTrue(viewModel.isHealthKitAvailable) + } + + func testUpdatePermissionsUsesHealthKitAvailability() { + Current.healthKitService.isAvailable = { false } + let viewModel = SensorListViewModel() + + viewModel.updatePermissions() + + XCTAssertFalse(viewModel.isHealthKitAvailable) + } + + func testUpdateAllSensorsIncludesHealthSensors() { + Current.sensors.setEnabled(false, forUniqueID: HealthKitSensor.Metric.steps.uniqueID) + let viewModel = SensorListViewModelWithoutRefresh() + viewModel.sensors = [ + WebhookSensor(name: "Health Steps", uniqueID: HealthKitSensor.Metric.steps.uniqueID), + ] + + viewModel.updateAllSensors(isEnabled: true) + + XCTAssertTrue(Current.sensors.isEnabled(uniqueID: HealthKitSensor.Metric.steps.uniqueID)) + } + + private final class SensorListViewModelWithoutRefresh: SensorListViewModel { + override func refresh() {} + } +} diff --git a/Tests/Shared/Sensors/HealthKitSensor.test.swift b/Tests/Shared/Sensors/HealthKitSensor.test.swift new file mode 100644 index 0000000000..d75ebbd173 --- /dev/null +++ b/Tests/Shared/Sensors/HealthKitSensor.test.swift @@ -0,0 +1,170 @@ +import Foundation +import PromiseKit +@testable import Shared +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 originalHealthKitService: HealthKitService! + private var originalSensors: SensorContainer! + private var previousDisabledSensors: Any? + + override func setUp() { + super.setUp() + + originalDate = Current.date + originalCalendar = Current.calendar + originalHealthKitService = Current.healthKitService + originalSensors = Current.sensors + 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.sensors = SensorContainer() + 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.healthKitService.isAvailable = { true } + Current.healthKitService.queryStepCount = { [weak self] _, _ in + self?.stepQueryCount += 1 + return 1234 + } + Current.healthKitService.queryLatestRestingHeartRate = { [weak self] _, _ in + self?.restingHeartRateQueryCount += 1 + return 62.4 + } + } + + override func tearDown() { + restore(previousDisabledSensors, forKey: "disabledSensors") + Current.date = originalDate + Current.calendar = originalCalendar + Current.healthKitService = originalHealthKitService + Current.sensors = originalSensors + originalDate = nil + originalCalendar = nil + originalHealthKitService = nil + originalSensors = 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.healthKitService.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.healthKitService.queryStepCount = { [weak self] _, _ in + self?.stepQueryCount += 1 + return nil + } + Current.healthKitService.queryLatestRestingHeartRate = { [weak self] _, _ in + self?.restingHeartRateQueryCount += 1 + return 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) + } +}