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