Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions Configuration/Entitlements/App-ios.entitlements
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@
<string>applinks:*.home-assistant.io</string>
<string>applinks:my.home-assistant.io</string>
</array>
<key>com.apple.developer.healthkit</key>
<true/>
<key>com.apple.developer.networking.wifi-info</key>
<true/>
<key>com.apple.developer.nfc.readersession.formats</key>
Expand Down
3 changes: 3 additions & 0 deletions HomeAssistant.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -3253,6 +3253,9 @@
com.apple.BackgroundModes = {
enabled = 1;
};
com.apple.HealthKit = {
enabled = 1;
};
com.apple.HomeKit = {
enabled = 0;
};
Expand Down
2 changes: 2 additions & 0 deletions Sources/App/Resources/Info.plist
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,8 @@
<string>Authenticate to access kiosk mode settings.</string>
<key>NSFocusStatusUsageDescription</key>
<string>Report your focus status as a sensor.</string>
<key>NSHealthShareUsageDescription</key>
<string>Read selected Apple Health metrics so they can be shared with Home Assistant as sensors.</string>
<key>NSLocalNetworkUsageDescription</key>
<string>Locate and communicate with your Home Assistant instance.</string>
<key>NSLocationAlwaysAndWhenInUseUsageDescription</key>
Expand Down
3 changes: 2 additions & 1 deletion Sources/App/Resources/en.lproj/InfoPlist.strings
Original file line number Diff line number Diff line change
Expand Up @@ -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.";
Expand All @@ -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.";
"TemporaryFullAccuracyReasonManualUpdate" = "Grant full accuracy to use your current location for your device tracker.";
9 changes: 8 additions & 1 deletion Sources/App/Resources/en.lproj/Localizable.strings
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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";
"yes_label" = "Yes";
37 changes: 37 additions & 0 deletions Sources/App/Settings/Sensors/List/SensorListView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ struct SensorListView: View {
subtitle: L10n.SettingsSensors.body
)
periodicUpdaterRow
healthKitSection
motionFocusPermissionNeededView
sensorsList
}
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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 {
Expand Down
9 changes: 9 additions & 0 deletions Sources/App/Settings/Sensors/List/SensorListViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -28,6 +29,8 @@ class SensorListViewModel: ObservableObject {
}

func updatePermissions() {
isHealthKitAvailable = Current.healthKitService.isAvailable()

if Current.motion.isActivityAvailable() {
motionAuthorizationStatus = CMMotionActivityManager.authorizationStatus()
} else {
Expand Down Expand Up @@ -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) {
Expand Down
130 changes: 130 additions & 0 deletions Sources/Shared/API/Webhook/Sensors/HealthKitSensor.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
#if os(iOS) && !targetEnvironment(macCatalyst)
import Foundation
import PromiseKit

public final class HealthKitSensor: SensorProvider {
Comment thread
bgoncal marked this conversation as resolved.
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
94 changes: 94 additions & 0 deletions Sources/Shared/API/Webhook/Sensors/HealthKitService.swift
Original file line number Diff line number Diff line change
@@ -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<HKSampleType>(),
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<HKObjectType> {
Set([
HKObjectType.quantityType(forIdentifier: .stepCount),
HKObjectType.quantityType(forIdentifier: .restingHeartRate),
].compactMap { $0 })
}
}
#endif
13 changes: 13 additions & 0 deletions Sources/Shared/API/Webhook/Sensors/HealthSensorValue.swift
Original file line number Diff line number Diff line change
@@ -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
Loading