Skip to content
Draft
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
17 changes: 17 additions & 0 deletions HomeAssistant.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -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 */; };
Expand Down Expand Up @@ -2880,6 +2884,9 @@
42AAEE892EC4C57F0049E1F3 /* ConnectionURLViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConnectionURLViewModel.swift; sourceTree = "<group>"; };
42ABB0B82C888AA10081461D /* CarPlayConfig.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CarPlayConfig.swift; sourceTree = "<group>"; };
42ABB0BA2C888BB10081461D /* CarPlayConfigurationViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CarPlayConfigurationViewModel.swift; sourceTree = "<group>"; };
42ABF0002FE3000000A1B2C3 /* HealthKitSensor.test.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HealthKitSensor.test.swift; sourceTree = "<group>"; };
42ABF0022FE3000000A1B2C3 /* HealthKitSensor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HealthKitSensor.swift; sourceTree = "<group>"; };
42ABF0062FE3000000A1B2C3 /* SensorListViewModelHealthKitTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SensorListViewModelHealthKitTests.swift; sourceTree = "<group>"; };
42AC10012FA1000000000001 /* PerformActionAppIntent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PerformActionAppIntent.swift; sourceTree = "<group>"; };
42AC10012FA1000000000002 /* AssistPromptAppIntent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AssistPromptAppIntent.swift; sourceTree = "<group>"; };
42AC10012FA1000000000003 /* GetCameraSnapshotAppIntent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GetCameraSnapshotAppIntent.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -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 */,
Expand Down Expand Up @@ -4349,6 +4357,7 @@
11BC9E5624FDC1C900B9FBF7 /* ActiveSensor.test.swift */,
110ED57F25A570F100489AF7 /* DisplaySensor.test.swift */,
115AD72C267C57DA0090B243 /* FocusSensor.test.swift */,
42ABF0002FE3000000A1B2C3 /* HealthKitSensor.test.swift */,
);
path = Sensors;
sourceTree = "<group>";
Expand Down Expand Up @@ -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 */,
Expand Down Expand Up @@ -8176,6 +8186,9 @@
com.apple.BackgroundModes = {
enabled = 1;
};
com.apple.HealthKit = {
enabled = 1;
};
com.apple.HomeKit = {
enabled = 0;
};
Expand Down Expand Up @@ -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 */,
Expand Down Expand Up @@ -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 */,
Expand Down Expand Up @@ -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 */,
Expand Down Expand Up @@ -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 */,
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 @@ -96,6 +96,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.";
8 changes: 8 additions & 0 deletions Sources/App/Resources/en.lproj/Localizable.strings
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
43 changes: 43 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,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 {
Expand Down Expand Up @@ -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
}
}
}
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 healthKitStatus: HealthKitSensor.AuthorizationStatus?
@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() {
healthKitStatus = Current.healthKit.authorizationStatus()

if Current.motion.isActivityAvailable() {
motionAuthorizationStatus = CMMotionActivityManager.authorizationStatus()
} else {
Expand Down Expand Up @@ -60,6 +63,12 @@ class SensorListViewModel: ObservableObject {
Current.settingsStore.periodicUpdateInterval = interval
}

func requestHealthAuthorization() -> Promise<Void> {
Current.healthKit.requestReadAuthorization().get { [weak self] in
self?.healthKitStatus = Current.healthKit.authorizationStatus()
}
}
Comment on lines +66 to +70

// MARK: - Permissions Handling

func requestMotionAuthorization(completion: @escaping () -> Void) {
Expand Down
154 changes: 154 additions & 0 deletions Sources/Shared/API/Webhook/Sensors/HealthKitSensor.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
import Foundation
import PromiseKit

public final class HealthKitSensor: SensorProvider {
Comment thread
bgoncal marked this conversation as resolved.
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<HealthSensorValue?>]> 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<HealthSensorValue?> {
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
}
}
Loading
Loading