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)
+ }
+}