Skip to content
Merged
Show file tree
Hide file tree
Changes from 9 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
Loading