From 955c17314651ba3eb52deb66bd361879dc7bfb41 Mon Sep 17 00:00:00 2001 From: Dave Date: Mon, 12 Oct 2026 15:21:08 -0400 Subject: [PATCH] feat(ios): export NOOP states to Home Assistant --- AGENTS.md | 8 +- StrandTests/HomeAssistantAPIClientTests.swift | 133 ++++++++++++++++ StrandiOS/App/RootTabView.swift | 21 ++- StrandiOS/App/StrandiOSApp.swift | 1 + .../Integration/HomeAssistantAPIClient.swift | 106 +++++++++++++ .../Integration/HomeAssistantExport.swift | 118 +++++++++++++++ .../Integration/HomeAssistantPayload.swift | 62 ++++++++ .../HomeAssistantSettingsView.swift | 143 ++++++++++++++++++ StrandiOS/Resources/Info.plist | 3 + docs/HOME_ASSISTANT.md | 33 ++++ docs/PRIVACY_SECURITY.md | 45 +++++- docs/SCOPE.md | 16 ++ project.yml | 8 +- 13 files changed, 686 insertions(+), 11 deletions(-) create mode 100644 StrandTests/HomeAssistantAPIClientTests.swift create mode 100644 StrandiOS/Integration/HomeAssistantAPIClient.swift create mode 100644 StrandiOS/Integration/HomeAssistantExport.swift create mode 100644 StrandiOS/Integration/HomeAssistantPayload.swift create mode 100644 StrandiOS/Integration/HomeAssistantSettingsView.swift create mode 100644 docs/HOME_ASSISTANT.md diff --git a/AGENTS.md b/AGENTS.md index ba94395511..31e823a44c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -14,9 +14,11 @@ NOOP is an **offline-by-default, on-device** companion app for WHOOP 4.0 and 5.0 strap). It pairs over Bluetooth, stores everything in on-device SQLite, and computes recovery / strain / HRV / sleep locally. There is **no NOOP-operated server, no account, no cloud dependency, no telemetry**, and the project stays **anonymous** (iOS/Android ship build-from-source / sideload, not -via the App Store). Issue #1314 permits one narrow exception: a default-off Experimental client may -export data one way to an HTTP(S) endpoint the user owns and configures. It must remain outside strap -sync, never read data back, and ship no receiver or hosted service in this repository. +via the App Store). Issue #1314 permits a default-off Experimental client to export data one way to +an HTTP(S) endpoint the user owns and configures; the iOS app also has the narrowly scoped, +default-off Home Assistant state export documented in [`docs/SCOPE.md`](docs/SCOPE.md). Both remain +outside strap sync, never read health data or commands back, and ship no receiver or hosted service +in this repository. These are hard constraints, not preferences. A PR is out of scope if it: - adds a server, account, cloud dependency, or sends data off-device without the explicit user export diff --git a/StrandTests/HomeAssistantAPIClientTests.swift b/StrandTests/HomeAssistantAPIClientTests.swift new file mode 100644 index 0000000000..54bb2ba7d1 --- /dev/null +++ b/StrandTests/HomeAssistantAPIClientTests.swift @@ -0,0 +1,133 @@ +import Foundation +import XCTest +import WhoopStore +@testable import Strand + +private enum HomeAssistantFakeTransport { + nonisolated(unsafe) static var requests: [URLRequest] = [] + + static func reset() { requests = [] } + + static func sender(status: Int) -> (URLRequest) async throws -> (Data, URLResponse) { + { request in + requests.append(request) + let response = HTTPURLResponse(url: request.url!, statusCode: status, + httpVersion: nil, headerFields: nil)! + return (Data(), response) + } + } +} + +final class HomeAssistantAPIClientTests: XCTestCase { + override func setUp() { + super.setUp() + HomeAssistantFakeTransport.reset() + } + + func testConnectionUsesAPIEndpointAndBearerTokenWithoutSendingHealthData() async throws { + let client = HomeAssistantAPIClient(baseURL: "http://homeassistant.local:8123/ha/", + token: "test-token", + send: HomeAssistantFakeTransport.sender(status: 200)) + + try await client.testConnection() + + let request = try XCTUnwrap(HomeAssistantFakeTransport.requests.first) + XCTAssertEqual(request.httpMethod, "GET") + XCTAssertEqual(request.url?.absoluteString, "http://homeassistant.local:8123/ha/api/") + XCTAssertEqual(request.value(forHTTPHeaderField: "Authorization"), "Bearer test-token") + XCTAssertNil(request.httpBody) + } + + func testSetStatePostsHomeAssistantPayloadAndAcceptsCreatedEntity() async throws { + let client = HomeAssistantAPIClient(baseURL: "https://ha.example.test", + token: "test-token", + send: HomeAssistantFakeTransport.sender(status: 201)) + + try await client.setState(.init(entityID: "sensor.noop_recovery", state: "82", + attributes: ["friendly_name": "NOOP Recovery", + "unit_of_measurement": "%", + "noop_day": "2026-10-11"])) + + let request = try XCTUnwrap(HomeAssistantFakeTransport.requests.first) + XCTAssertEqual(request.httpMethod, "POST") + XCTAssertEqual(request.url?.absoluteString, "https://ha.example.test/api/states/sensor.noop_recovery") + XCTAssertEqual(request.value(forHTTPHeaderField: "Authorization"), "Bearer test-token") + XCTAssertEqual(request.value(forHTTPHeaderField: "Content-Type"), "application/json") + let body = try XCTUnwrap(request.httpBody) + let payload = try XCTUnwrap(JSONSerialization.jsonObject(with: body) as? [String: Any]) + XCTAssertEqual(payload["state"] as? String, "82") + let attributes = try XCTUnwrap(payload["attributes"] as? [String: String]) + XCTAssertEqual(attributes["friendly_name"], "NOOP Recovery") + XCTAssertEqual(attributes["unit_of_measurement"], "%") + XCTAssertEqual(attributes["noop_day"], "2026-10-11") + } + + func testUnauthorizedResponseSurfacesHomeAssistantStatus() async { + let client = HomeAssistantAPIClient(baseURL: "https://ha.example.test", token: "bad-token", + send: HomeAssistantFakeTransport.sender(status: 401)) + + do { + try await client.testConnection() + XCTFail("expected unauthorized response") + } catch let error as HomeAssistantAPIClient.APIError { + XCTAssertEqual(error, .response(401)) + } catch { + XCTFail("unexpected error: \(error)") + } + } + + func testRejectsUnsafeOrMalformedConfiguration() async { + XCTAssertTrue(HomeAssistantAPIClient.isValidBaseURL("http://192.168.1.20:8123")) + XCTAssertTrue(HomeAssistantAPIClient.isValidBaseURL("https://ha.example.test/ha")) + XCTAssertFalse(HomeAssistantAPIClient.isValidBaseURL("ftp://ha.example.test")) + XCTAssertFalse(HomeAssistantAPIClient.isValidBaseURL("https://user:pass@ha.example.test")) + XCTAssertFalse(HomeAssistantAPIClient.isValidBaseURL("https://ha.example.test?token=secret")) + + let client = HomeAssistantAPIClient(baseURL: "https://ha.example.test", token: "token", + send: HomeAssistantFakeTransport.sender(status: 200)) + do { + try await client.setState(.init(entityID: "sensor.noop_recovery/../../other", state: "82", attributes: [:])) + XCTFail("expected invalid entity ID") + } catch let error as HomeAssistantAPIClient.APIError { + XCTAssertEqual(error, .invalidEntityID) + } catch { + XCTFail("unexpected error: \(error)") + } + XCTAssertTrue(HomeAssistantFakeTransport.requests.isEmpty) + } + + func testPayloadMapsNOOPMetricsToNamedHomeAssistantSensors() { + let day = DailyMetric(day: "2026-10-11", totalSleepMin: 412.6, efficiency: 0.91, + deepMin: nil, remMin: nil, lightMin: nil, disturbances: nil, + restingHr: 52, avgHrv: 67.5, recovery: 84, strain: 12.3, + exerciseCount: nil, spo2Pct: 97.4, respRateBpm: 15.8, + steps: 8_765, skinTempC: 33.2) + + let updates = HomeAssistantPayload.states(day: day, recentHeartRate: 68, + batterySoc: 78.26, charging: false) + let byID = Dictionary(uniqueKeysWithValues: updates.map { ($0.entityID, $0) }) + + XCTAssertEqual(updates.count, 13) + XCTAssertEqual(byID["sensor.noop_heart_rate"]?.state, "68") + XCTAssertEqual(byID["sensor.noop_recovery"]?.state, "84.0") + XCTAssertEqual(byID["sensor.noop_sleep_duration"]?.state, "413") + XCTAssertEqual(byID["sensor.noop_sleep_efficiency"]?.state, "91.0") + XCTAssertEqual(byID["sensor.noop_battery"]?.state, "78.3") + XCTAssertEqual(byID["sensor.noop_spo2"]?.attributes["unit_of_measurement"] as? String, "%") + XCTAssertEqual(byID["binary_sensor.noop_strap_charging"]?.state, "off") + XCTAssertEqual(byID["sensor.noop_recovery"]?.attributes["noop_day"] as? String, "2026-10-11") + } + + func testPayloadOmitsUnavailableOrNonFiniteMeasurements() { + let day = DailyMetric(day: "2026-10-11", totalSleepMin: .infinity, efficiency: .nan, + deepMin: nil, remMin: nil, lightMin: nil, disturbances: nil, + restingHr: nil, avgHrv: .nan, recovery: nil, strain: nil, + exerciseCount: nil, spo2Pct: nil, respRateBpm: nil, + steps: nil, skinTempC: nil) + + let updates = HomeAssistantPayload.states(day: day, recentHeartRate: nil, + batterySoc: .infinity, charging: nil) + + XCTAssertTrue(updates.isEmpty) + } +} diff --git a/StrandiOS/App/RootTabView.swift b/StrandiOS/App/RootTabView.swift index 41c6829964..48139f207f 100644 --- a/StrandiOS/App/RootTabView.swift +++ b/StrandiOS/App/RootTabView.swift @@ -40,6 +40,7 @@ struct RootTabView: View { @State private var quickAction: QuickAction? /// Presents the Devices manager (pair / switch bands) when a screen asks the shell to open it. @State private var showDevices = false + @State private var homeAssistantExportTask: Task? /// A routed v5 pillar screen (Insights hub / Lab Book / fused record / Rhythm) presented as a sheet /// when a hub row deep-links to it via NavRouter. nil = closed. @State private var routedPillar: NavRouter.Destination? @@ -99,6 +100,19 @@ struct RootTabView: View { } } + /// Automatic Home Assistant delivery is driven by completed local repository refreshes. It is a + /// separate task, so network latency can never sit on the BLE offload/write path. + private func exportToHomeAssistantIfEnabled() { + guard UserDefaults.standard.bool(forKey: HomeAssistantExport.enabledKey), + homeAssistantExportTask == nil else { return } + let endpoint = UserDefaults.standard.string(forKey: HomeAssistantExport.endpointKey) ?? "" + guard HomeAssistantExport.hasToken(), !endpoint.isEmpty else { return } + homeAssistantExportTask = Task { @MainActor in + defer { homeAssistantExportTask = nil } + _ = try? await HomeAssistantExport.exportLatest(repository: repo, baseURL: endpoint) + } + } + /// The anywhere-swipe tab-switch drag (2026-07-02). Held as a property so the attachment site can /// enable or disable it through a `GestureMask` instead of attaching it conditionally: a conditional /// attachment changes view identity, and this condition toggles on every push and pop, which would @@ -147,6 +161,9 @@ struct RootTabView: View { .onChangeCompat(of: coachEnabled) { enabled in if !enabled && selectedTab == 3 { selectedTab = 0 } } + .onChangeCompat(of: repo.refreshSeq) { _ in + exportToHomeAssistantIfEnabled() + } // #1841: the same "Hide bar when scrolling" preference Android drives its own bar with. Here the // system owns the behaviour — iOS 26's tab bar MINIMISES to a pill on scroll down rather than // sliding away entirely, so this is the platform's read of the same intent, not a copy of ours. @@ -492,6 +509,7 @@ struct RootTabView: View { // #155: HealthKit-free Apple Health path for sideloaded installs (Siri Shortcut // reads the opt-in Documents/noop_sync.txt drop file). MoreRow("Shortcuts Export", "square.and.arrow.up.fill", .shortcutsExport) + MoreRow("Home Assistant", "house.fill", .homeAssistant) // The plain 4.0 vs 5.0/MG capability grid — what NOOP reads live off each strap. MoreRow("NOOP Limitations", "list.bullet.rectangle", .noopLimitations) } @@ -599,7 +617,7 @@ struct RootTabView: View { private enum MoreDestination: Hashable { case insightsHub, intelligence, coach, insights, explore, compare case live, workouts, liftLog, health, labBook, stress, breathe, intervals, rhythm - case fusedRecord, appleHealth, miBand, dataSources, backupSync, shortcutsExport, noopLimitations + case fusedRecord, appleHealth, miBand, dataSources, backupSync, shortcutsExport, homeAssistant, noopLimitations case alarms, automations, testCentre, siriShortcuts, powerSaving, settings @ViewBuilder var destination: some View { @@ -626,6 +644,7 @@ private enum MoreDestination: Hashable { case .noopLimitations: NoopLimitationsView() case .backupSync: BackupSyncView() case .shortcutsExport: ShortcutExportSettingsView() + case .homeAssistant: HomeAssistantSettingsView() case .alarms: SmartAlarmView() case .automations: AutomationsView() case .testCentre: TestCentreView() diff --git a/StrandiOS/App/StrandiOSApp.swift b/StrandiOS/App/StrandiOSApp.swift index d505bc60d3..c1bd796aa8 100644 --- a/StrandiOS/App/StrandiOSApp.swift +++ b/StrandiOS/App/StrandiOSApp.swift @@ -589,6 +589,7 @@ enum DemoScreens { case "explore": return AnyView(MetricExplorerView()) case "compare": return AnyView(CompareView()) case "settings": return AnyView(SettingsView()) + case "homeassistant": return AnyView(HomeAssistantSettingsView()) case "chargebreakdown": return AnyView(ChargeBreakdownDemoHost()) case "devices": return AnyView(DevicesView()) case "devicescatalog": return AnyView(DeviceCardCatalog()) diff --git a/StrandiOS/Integration/HomeAssistantAPIClient.swift b/StrandiOS/Integration/HomeAssistantAPIClient.swift new file mode 100644 index 0000000000..4c0f29b5f9 --- /dev/null +++ b/StrandiOS/Integration/HomeAssistantAPIClient.swift @@ -0,0 +1,106 @@ +import Foundation + +/// Small, injectable Home Assistant REST client. Kept separate from repository and Keychain access so +/// the exact requests used by iOS can be verified against a deterministic fake API. +struct HomeAssistantAPIClient { + struct StateUpdate { + let entityID: String + let state: String + let attributes: [String: Any] + } + + enum APIError: LocalizedError, Equatable { + case invalidURL + case invalidEntityID + case response(Int) + case invalidResponse + + var errorDescription: String? { + switch self { + case .invalidURL: return "Enter a valid Home Assistant URL using http or https." + case .invalidEntityID: return "NOOP generated an invalid Home Assistant entity ID." + case .response(let code): return "Home Assistant returned HTTP \(code). Check the URL and token." + case .invalidResponse: return "Home Assistant returned an unexpected response." + } + } + } + + private let baseURL: String + private let token: String + private let send: (URLRequest) async throws -> (Data, URLResponse) + + init(baseURL: String, token: String, send: ((URLRequest) async throws -> (Data, URLResponse))? = nil) { + self.baseURL = baseURL + self.token = token + if let send { + self.send = send + } else { + let configuration = URLSessionConfiguration.ephemeral + let session = URLSession(configuration: configuration, + delegate: HomeAssistantNoRedirectDelegate(), + delegateQueue: nil) + self.send = { try await session.data(for: $0) } + } + } + + static func isValidBaseURL(_ baseURL: String) -> Bool { + apiURL(baseURL, path: "api/") != nil + } + + func testConnection() async throws { + guard let url = Self.apiURL(baseURL, path: "api/") else { throw APIError.invalidURL } + var request = URLRequest(url: url) + request.httpMethod = "GET" + request.setValue("application/json", forHTTPHeaderField: "Accept") + try await perform(request) + } + + func setState(_ update: StateUpdate) async throws { + guard update.entityID.range(of: #"^(sensor|binary_sensor)\.[a-z0-9_]+$"#, + options: .regularExpression) != nil else { + throw APIError.invalidEntityID + } + guard let url = Self.apiURL(baseURL, path: "api/states/\(update.entityID)") else { + throw APIError.invalidURL + } + var request = URLRequest(url: url) + request.httpMethod = "POST" + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.httpBody = try JSONSerialization.data(withJSONObject: [ + "state": update.state, + "attributes": update.attributes + ]) + try await perform(request) + } + + private func perform(_ request: URLRequest) async throws { + var request = request + request.timeoutInterval = 20 + request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") + let (_, response) = try await send(request) + guard let http = response as? HTTPURLResponse else { throw APIError.invalidResponse } + guard (200..<300).contains(http.statusCode) else { throw APIError.response(http.statusCode) } + } + + private static func apiURL(_ baseURL: String, path: String) -> URL? { + let raw = baseURL.trimmingCharacters(in: .whitespacesAndNewlines) + .trimmingCharacters(in: CharacterSet(charactersIn: "/")) + guard var components = URLComponents(string: raw), + let scheme = components.scheme?.lowercased(), scheme == "https" || scheme == "http", + components.host != nil, components.user == nil, components.password == nil, + components.query == nil, components.fragment == nil else { return nil } + let prefix = components.path.trimmingCharacters(in: CharacterSet(charactersIn: "/")) + components.path = "/" + ([prefix, path].filter { !$0.isEmpty }.joined(separator: "/")) + return components.url + } +} + +private final class HomeAssistantNoRedirectDelegate: NSObject, URLSessionTaskDelegate { + func urlSession(_ session: URLSession, task: URLSessionTask, + willPerformHTTPRedirection response: HTTPURLResponse, + newRequest request: URLRequest, + completionHandler: @escaping (URLRequest?) -> Void) { + // Never forward the long-lived token to a different host through a redirect. + completionHandler(nil) + } +} diff --git a/StrandiOS/Integration/HomeAssistantExport.swift b/StrandiOS/Integration/HomeAssistantExport.swift new file mode 100644 index 0000000000..e3c15074f5 --- /dev/null +++ b/StrandiOS/Integration/HomeAssistantExport.swift @@ -0,0 +1,118 @@ +#if os(iOS) +import Foundation +import Security + +/// User-owned, one-way publisher for the latest NOOP daily metrics and strap battery to Home Assistant. +/// The long-lived Home Assistant token stays in Keychain. Calls happen only after an explicit manual +/// export or while the user's default-off automatic-export switch is enabled. +enum HomeAssistantExport { + static let enabledKey = "noop.homeAssistantExportEnabled" + static let endpointKey = "noop.homeAssistantBaseURL" + private static let keychainService = "ca.zenastra.noop.home-assistant" + private static let keychainAccount = "long-lived-access-token" + + struct ResultSummary { + let entitiesUpdated: Int + let day: String? + } + + enum ExportError: LocalizedError { + case invalidURL + case missingConfiguration + case missingDailyData + case response(Int) + case invalidResponse + case keychain(OSStatus) + + var errorDescription: String? { + switch self { + case .invalidURL: return "Enter a valid Home Assistant URL using http or https." + case .missingConfiguration: return "Save the Home Assistant URL and access token first." + case .missingDailyData: return "NOOP has no daily metrics to send yet. Sync your WHOOP strap first." + case .response(let code): return "Home Assistant returned HTTP \(code). Check the URL and token." + case .invalidResponse: return "Home Assistant returned an unexpected response." + case .keychain(let status): return "iOS Keychain could not save the access token (\(status))." + } + } + } + + static func saveToken(_ token: String) throws { + let trimmed = token.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { throw ExportError.missingConfiguration } + let base: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: keychainService, + kSecAttrAccount as String: keychainAccount + ] + let value: [String: Any] = [ + kSecValueData as String: Data(trimmed.utf8), + kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly + ] + let updateStatus = SecItemUpdate(base as CFDictionary, value as CFDictionary) + if updateStatus == errSecItemNotFound { + var item = base + value.forEach { item[$0.key] = $0.value } + let addStatus = SecItemAdd(item as CFDictionary, nil) + guard addStatus == errSecSuccess else { throw ExportError.keychain(addStatus) } + } else if updateStatus != errSecSuccess { + throw ExportError.keychain(updateStatus) + } + } + + static func hasToken() -> Bool { token() != nil } + static func isValidBaseURL(_ baseURL: String) -> Bool { HomeAssistantAPIClient.isValidBaseURL(baseURL) } + + static func clearToken() { + SecItemDelete([ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: keychainService, + kSecAttrAccount as String: keychainAccount + ] as CFDictionary) + } + + static func testConnection(baseURL: String) async throws { + guard let token = token() else { throw ExportError.missingConfiguration } + let client = HomeAssistantAPIClient(baseURL: baseURL, token: token) + do { try await client.testConnection() } + catch HomeAssistantAPIClient.APIError.invalidURL { throw ExportError.invalidURL } + catch { throw error } + } + + @MainActor + static func exportLatest(repository: Repository, baseURL: String) async throws -> ResultSummary { + guard let token = token() else { throw ExportError.missingConfiguration } + guard HomeAssistantAPIClient.isValidBaseURL(baseURL) else { throw ExportError.invalidURL } + guard let day = repository.days.last else { throw ExportError.missingDailyData } + + let battery = await repository.latestBattery() + let now = Int(Date().timeIntervalSince1970) + let recentHR = await repository.hrSamples(deviceIds: repository.importedReadIds, + from: now - 300, to: now, limit: 100).last + let entities = HomeAssistantPayload.states(day: day, recentHeartRate: recentHR?.bpm, + batterySoc: battery?.soc, charging: battery?.charging) + guard !entities.isEmpty else { throw ExportError.missingDailyData } + + let client = HomeAssistantAPIClient(baseURL: baseURL, token: token) + for entity in entities { + do { try await client.setState(entity) } + catch HomeAssistantAPIClient.APIError.invalidURL { throw ExportError.invalidURL } + catch { throw error } + } + return ResultSummary(entitiesUpdated: entities.count, day: day.day) + } + + private static func token() -> String? { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: keychainService, + kSecAttrAccount as String: keychainAccount, + kSecReturnData as String: true, + kSecMatchLimit as String: kSecMatchLimitOne + ] + var result: CFTypeRef? + guard SecItemCopyMatching(query as CFDictionary, &result) == errSecSuccess, + let data = result as? Data else { return nil } + return String(data: data, encoding: .utf8) + } +} +#endif diff --git a/StrandiOS/Integration/HomeAssistantPayload.swift b/StrandiOS/Integration/HomeAssistantPayload.swift new file mode 100644 index 0000000000..6ca788453e --- /dev/null +++ b/StrandiOS/Integration/HomeAssistantPayload.swift @@ -0,0 +1,62 @@ +import Foundation +import WhoopStore + +/// Converts the values already banked by NOOP into Home Assistant state updates. +/// Kept pure so sensor names, units, and unavailable-value handling can be verified without a strap. +enum HomeAssistantPayload { + static func states(day: DailyMetric, recentHeartRate: Int?, batterySoc: Double?, charging: Bool?) + -> [HomeAssistantAPIClient.StateUpdate] { + var result: [HomeAssistantAPIClient.StateUpdate] = [] + + func add(_ id: String, _ name: String, _ value: String?, unit: String? = nil) { + guard let value else { return } + var attributes: [String: Any] = [ + "friendly_name": "NOOP \(name)", + "noop_day": day.day, + "state_class": "measurement" + ] + if let unit { attributes["unit_of_measurement"] = unit } + result.append(.init(entityID: "sensor.noop_\(id)", state: value, attributes: attributes)) + } + + func decimal(_ value: Double?) -> String? { + guard let value, value.isFinite else { return nil } + return String(value) + } + + // Keep the stable entity IDs and units in sync with the existing NOOP MQTT package in + // home-assistant/packages/noop_health.yaml. Direct REST export is the transport here, but HA + // and Jarvis should see the same canonical sensors as the MQTT bridge. + add("recovery", "Recovery", decimal(day.recovery), unit: "%") + add("strain", "Strain", decimal(day.strain)) + add("sleep_hours", "Sleep Hours", + day.totalSleepMin.flatMap { $0.isFinite ? String($0 / 60) : nil }, unit: "h") + add("sleep_efficiency", "Sleep Efficiency", + day.efficiency.flatMap { $0.isFinite ? String(($0 * 1_000).rounded() / 10) : nil }, unit: "%") + add("hrv", "HRV", decimal(day.avgHrv), unit: "ms") + add("resting_hr", "Resting HR", day.restingHr.map(String.init), unit: "bpm") + add("steps", "Steps", day.steps.map(String.init), unit: "steps") + add("spo2", "SpO2", decimal(day.spo2Pct), unit: "%") + add("respiratory_rate", "Respiratory Rate", decimal(day.respRateBpm), unit: "rpm") + add("skin_temp_deviation", "Skin Temp Deviation", decimal(day.skinTempDevC), unit: "°C") + add("strap_battery", "Strap Battery", + batterySoc.flatMap { $0.isFinite ? String(($0 * 10).rounded() / 10) : nil }, unit: "%") + + let generatedAt = ISO8601DateFormatter().string(from: Date()) + result.append(.init(entityID: "sensor.noop_last_sync", state: generatedAt, + attributes: ["friendly_name": "NOOP Last Sync", "device_class": "timestamp"])) + var statusAttributes: [String: Any] = ["friendly_name": "NOOP Status", "noop_day": day.day] + if let recentHeartRate { statusAttributes["heart_rate_bpm"] = recentHeartRate } + result.append(.init(entityID: "sensor.noop_status", state: "ok", + attributes: statusAttributes)) + + if let charging { + result.append(.init(entityID: "binary_sensor.noop_strap_charging", + state: charging ? "on" : "off", + attributes: ["friendly_name": "NOOP Strap Charging", + "device_class": "battery_charging", + "noop_day": day.day])) + } + return result + } +} diff --git a/StrandiOS/Integration/HomeAssistantSettingsView.swift b/StrandiOS/Integration/HomeAssistantSettingsView.swift new file mode 100644 index 0000000000..971c6cb140 --- /dev/null +++ b/StrandiOS/Integration/HomeAssistantSettingsView.swift @@ -0,0 +1,143 @@ +#if os(iOS) +import SwiftUI +import StrandDesign + +struct HomeAssistantSettingsView: View { + @EnvironmentObject private var repo: Repository + @AppStorage(HomeAssistantExport.endpointKey) private var endpoint = "" + @AppStorage(HomeAssistantExport.enabledKey) private var automaticExport = false + @State private var token = "" + @State private var hasSavedToken = HomeAssistantExport.hasToken() + @State private var isWorking = false + @State private var status: String? + @State private var statusIsError = false + + var body: some View { + ScreenScaffold(title: "Home Assistant", subtitle: "Send WHOOP data from NOOP to your own Home Assistant instance.") { + VStack(spacing: 14) { + connectionCard + exportCard + } + } + } + + private var connectionCard: some View { + StrandCard(padding: 20) { + VStack(alignment: .leading, spacing: 12) { + Label("Connection", systemImage: "house.fill") + .font(StrandFont.headline).foregroundStyle(StrandPalette.textPrimary) + TextField("https://homeassistant.example.com", text: $endpoint) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + .keyboardType(.URL) + .textFieldStyle(.roundedBorder) + SecureField(hasSavedToken ? "Access token saved in Keychain" : "Long-lived access token", text: $token) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + .textFieldStyle(.roundedBorder) + HStack { + Button("Save connection", action: saveConnection) + .buttonStyle(.borderedProminent) + .tint(StrandPalette.accent) + Button("Test connection", action: testConnection) + .buttonStyle(.bordered) + .disabled(isWorking || !hasSavedToken) + } + if hasSavedToken { + Button("Remove saved token", role: .destructive) { + HomeAssistantExport.clearToken() + hasSavedToken = false + token = "" + status = "Saved token removed from this iPhone." + statusIsError = false + } + .font(StrandFont.caption) + } + Text("Create a Home Assistant long-lived access token in your HA profile. The token stays in iOS Keychain. NOOP only writes sensor states; it never reads data or commands from Home Assistant.") + .font(StrandFont.caption).foregroundStyle(StrandPalette.textTertiary) + .fixedSize(horizontal: false, vertical: true) + if let status { + Text(status).font(StrandFont.caption) + .foregroundStyle(statusIsError ? .red : StrandPalette.accent) + .fixedSize(horizontal: false, vertical: true) + } + } + } + } + + private var exportCard: some View { + StrandCard(padding: 20) { + VStack(alignment: .leading, spacing: 10) { + Label("NOOP sensors", systemImage: "waveform.path.ecg") + .font(StrandFont.headline).foregroundStyle(StrandPalette.textPrimary) + Toggle(isOn: $automaticExport) { + Text("Automatically export after data refresh") + .font(StrandFont.subhead).foregroundStyle(StrandPalette.textPrimary) + } + .toggleStyle(.switch).tint(StrandPalette.accent) + .disabled(!hasSavedToken || endpoint.isEmpty) + Text("Publishes a recent heart-rate sample plus the latest available daily recovery, strain, sleep, HRV, resting heart rate, steps, SpO₂, respiration, skin temperature, and strap battery as sensor entities. Automatic export is off until you turn it on.") + .font(StrandFont.caption).foregroundStyle(StrandPalette.textTertiary) + .fixedSize(horizontal: false, vertical: true) + Button(action: exportNow) { + HStack { + if isWorking { ProgressView().tint(.white) } + Text("Send latest data now") + } + } + .buttonStyle(.borderedProminent).tint(StrandPalette.accent) + .disabled(isWorking || !hasSavedToken || endpoint.isEmpty) + Text("Home Assistant's REST state API creates entities such as sensor.noop_recovery. States appear after export and are refreshed on the next enabled export.") + .font(StrandFont.caption).foregroundStyle(StrandPalette.textTertiary) + .fixedSize(horizontal: false, vertical: true) + } + } + } + + private func saveConnection() { + do { + guard HomeAssistantExport.isValidBaseURL(endpoint) else { + throw HomeAssistantExport.ExportError.invalidURL + } + if !token.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + try HomeAssistantExport.saveToken(token) + token = "" + } else if !HomeAssistantExport.hasToken() { + throw HomeAssistantExport.ExportError.missingConfiguration + } + hasSavedToken = HomeAssistantExport.hasToken() + status = "Connection settings saved." + statusIsError = false + } catch { show(error) } + } + + private func testConnection() { + isWorking = true + Task { + do { + try await HomeAssistantExport.testConnection(baseURL: endpoint) + status = "Connected to Home Assistant. No health data was sent." + statusIsError = false + } catch { show(error) } + isWorking = false + } + } + + private func exportNow() { + isWorking = true + Task { + do { + let result = try await HomeAssistantExport.exportLatest(repository: repo, baseURL: endpoint) + status = "Updated \(result.entitiesUpdated) sensor states for \(result.day ?? "the latest day")." + statusIsError = false + } catch { show(error) } + isWorking = false + } + } + + private func show(_ error: Error) { + status = error.localizedDescription + statusIsError = true + } +} +#endif diff --git a/StrandiOS/Resources/Info.plist b/StrandiOS/Resources/Info.plist index 10b0a7681c..013a7dbec1 100644 --- a/StrandiOS/Resources/Info.plist +++ b/StrandiOS/Resources/Info.plist @@ -10,6 +10,7 @@ $(PRODUCT_BUNDLE_IDENTIFIER).coachbrief $(PRODUCT_BUNDLE_IDENTIFIER).healthwriteback $(PRODUCT_BUNDLE_IDENTIFIER).rescore + $(PRODUCT_BUNDLE_IDENTIFIER).stalebattery CFBundleDevelopmentRegion $(DEVELOPMENT_LANGUAGE) @@ -66,6 +67,8 @@ NOOP reads your own Apple Health data on-device to compute recovery, strain, and sleep. Nothing leaves your device. NSHealthUpdateUsageDescription NOOP writes the metrics it computes from your strap back into Apple Health, on-device and only when you allow it. + NSLocalNetworkUsageDescription + NOOP connects to the Home Assistant and Coach servers you configure on your local network. NSLocationWhenInUseUsageDescription NOOP records your route during an outdoor workout to show distance, pace, and a map, and keeps recording while the screen is off. The route stays on your device. Nothing leaves it. NSMicrophoneUsageDescription diff --git a/docs/HOME_ASSISTANT.md b/docs/HOME_ASSISTANT.md new file mode 100644 index 0000000000..b040a9e507 --- /dev/null +++ b/docs/HOME_ASSISTANT.md @@ -0,0 +1,33 @@ +# iOS Home Assistant export + +The iOS Home Assistant integration is configured by the device owner. It has no NOOP-operated +intermediary and is off until enabled. + +## Configure the connection + +1. In NOOP, open **More → Home Assistant**. +2. Enter the base URL of the Home Assistant instance, without an `/api` suffix. Use HTTPS when the + instance is reachable beyond a trusted private network. +3. Paste a Home Assistant long-lived access token and choose **Save connection**. NOOP stores the + token in iOS Keychain; it is not written to app preferences or the URL. +4. Choose **Test connection**. This checks the authenticated Home Assistant API root and sends no + health data. +5. Choose **Send latest data now** to publish the latest available metrics. To send after later + local data refreshes, turn on **Automatically export after data refresh**. This switch is off by + default and can be turned off at any time. + +## Published entities + +NOOP writes state-backed sensors with stable IDs: `sensor.noop_recovery`, `sensor.noop_strain`, +`sensor.noop_sleep_hours`, `sensor.noop_sleep_efficiency`, `sensor.noop_hrv`, +`sensor.noop_resting_hr`, `sensor.noop_steps`, `sensor.noop_spo2`, +`sensor.noop_respiratory_rate`, `sensor.noop_skin_temp_deviation`, +`sensor.noop_strap_battery`, `sensor.noop_last_sync`, and `sensor.noop_status`. When known, it also +writes `binary_sensor.noop_strap_charging`. Missing measurements are omitted. The API creates +state-backed entities when they are first published; these are not managed through Home Assistant's +entity registry and may need to be republished after a Home Assistant restart. + +The integration writes only the latest daily summary, one recent heart-rate sample as an attribute, +and the current battery state. It does not upload historical samples, query HA state, invoke HA +services, or receive commands. See the precise boundary in [`SCOPE.md`](SCOPE.md#ios-home-assistant-export) +and [`PRIVACY_SECURITY.md`](PRIVACY_SECURITY.md#11e-home-assistant-export-ios-off-by-default). diff --git a/docs/PRIVACY_SECURITY.md b/docs/PRIVACY_SECURITY.md index bd1da6f201..3e07beaab8 100644 --- a/docs/PRIVACY_SECURITY.md +++ b/docs/PRIVACY_SECURITY.md @@ -54,6 +54,7 @@ these are features, not a count of individual requests or destinations. | Path / platforms | Default and trigger | What is sent | Destination | | --- | --- | --- | --- | | AI Coach / macOS, iOS, Android (§1.1a) | Unconfigured initially; requests after provider setup for chat, model refresh or enabled briefs | Provider authentication when required; conversation text; health summaries only with data consent; optional chart attachment on Apple/Gemini | Chosen provider or Custom server | +| Home Assistant export / iOS (§1.1e) | Off by default; manual export or after local data refresh when enabled | Bearer token; latest daily metric values, recent heart-rate sample and strap battery state | User-configured Home Assistant REST endpoint | | Oura history import / opt-in macOS, iOS build (§1.1b) | Compiled out by default; user starts authorization/import | OAuth credentials, authorization/refresh tokens and history query parameters; no existing NOOP health data | `cloud.ouraring.com`, `api.ouraring.com` | | Update check / macOS, iOS, Android (§1.1c) | Automatic check **on by default**, at most daily after onboarding/Terms; manual check on tap | Public release `GET`; no health data or app-added user/account identifier | `api.github.com` | | Self-hosted push / Android (§1.1d) | Experimental, **off by default**; configured export after offload/launch or on tap; explicit connection test | Bearer authentication; capability `GET`; versioned health batches with local installation/device IDs on export | User-owned HTTP(S) endpoint | @@ -65,12 +66,12 @@ Opening a web link hands it to a browser; Apple Health/Health Connect and sidelo own network and sharing settings. This inventory does not promise those services are offline. For ordinary strap collection without NOOP's direct HTTP requests: turn off **Check automatically** -in Settings → About, leave the AI Coach and self-hosted push unconfigured, use a default build -without the Oura cloud import, and do not tap the manual update check. This does not disable -platform-service traffic such as the route map. +in Settings → About, leave the AI Coach and self-hosted push unconfigured, keep Home Assistant +automatic export off, use a default build without the Oura cloud import, and do not tap the manual +update check. This does not disable platform-service traffic such as the route map. Networking clients live in the app targets: `Strand/AI/`, `Strand/Oura/`, `Strand/System/UpdateChecker.swift`, -and Android's `ai/`, `update/` and `push/` packages. The biometric pipeline and Swift packages under +`StrandiOS/Integration/`, and Android's `ai/`, `update/` and `push/` packages. The biometric pipeline and Swift packages under `Packages/` do not make runtime network requests; shared parsers such as `SseDeltas` only process already-received text. Android's Oura support is local BLE, with no equivalent to the cloud import. @@ -214,6 +215,37 @@ not a NOOP cloud, account, restore path, or two-way sync: The repository ships the Android client and protocol document, not a server. The feature remains Experimental and default-off under the boundary in [`SCOPE.md`](SCOPE.md). +### 1.1e Home Assistant export (iOS, off by default) + +The iOS app can publish a fixed set of the latest NOOP values to a Home Assistant instance the user +configures. It is separate from BLE collection and never reads Home Assistant entities or commands: + +- **Off until enabled.** The user saves a Home Assistant URL and long-lived access token in + **More → Home Assistant**. The token is stored in iOS Keychain. A manual **Send latest data now** + action sends immediately; automatic export runs after a local repository refresh only when its + switch is enabled. **Test connection** sends only an authenticated `GET /api/` and no health data. +- **What is sent.** The latest available daily recovery, strain, sleep, HRV, resting heart rate, + steps, SpO₂, respiratory rate, skin-temperature deviation and strap battery values, plus a recent + heart-rate sample in the status sensor attributes and a sync timestamp. Unavailable values are + omitted. Each value is written separately with `POST /api/states/`; no database or BLE + history is uploaded. +- **What comes back.** Only the HTTP success/failure status is used. NOOP does not query entity + state, issue Home Assistant services, or change collection based on Home Assistant availability. + HTTP redirects are not followed, so the bearer token is not forwarded to another host. +- **Destination and transport.** Requests go only to the URL entered by the user. HTTPS is + recommended. On a trusted private network, a user may choose a local HTTP URL; the token and + values then travel in cleartext on that network. iOS may require Local Network permission for a + local Home Assistant host. +- **Entity behavior.** The REST state API creates state-backed entities such as + `sensor.noop_recovery`; they are not configured through Home Assistant's entity registry and may + need to be republished after a Home Assistant restart. + +Code: `StrandiOS/Integration/HomeAssistantAPIClient.swift`, +`StrandiOS/Integration/HomeAssistantPayload.swift`, +`StrandiOS/Integration/HomeAssistantExport.swift`, and +`StrandiOS/Integration/HomeAssistantSettingsView.swift`. Setup notes are in +[`HOME_ASSISTANT.md`](HOME_ASSISTANT.md). + ### 1.2 The macOS sandbox (and what it means for the AI Coach and the Oura import) On macOS the App Sandbox is the backstop. The app ships with a minimal entitlement set @@ -605,8 +637,9 @@ dedicated source id `nutrition-csv`, alongside your other metrics and entirely o Keychain. - **No telemetry / analytics / crash reporting.** No third-party SDKs of that kind. - **No NOOP cloud, account sync, or operated remote backup.** The Oura history import (§1.1b) is - **inbound only**. Android self-hosted push (§1.1d) is the sole standing outbound export: off by - default, one-way, and directed only to the endpoint the user configured and owns. + **inbound only**. The Android self-hosted push (§1.1d) and iOS Home Assistant export (§1.1e) are + the only standing health-data exports: both are off by default, one-way, and directed only to an + endpoint the user configures. - **No advertising identifiers, no tracking.** - **No WHOOP account or API credentials.** NOOP talks only to the strap over local BLE; it does not authenticate against, or pull from, any WHOOP server. (Oura is the diff --git a/docs/SCOPE.md b/docs/SCOPE.md index c43f146b8f..35b5ce4657 100644 --- a/docs/SCOPE.md +++ b/docs/SCOPE.md @@ -66,6 +66,22 @@ read-back, remote writes, or making app behaviour depend on endpoint availabilit scope. The feature stays Experimental until its Android background behaviour is proven quiet and reliable in real-world use. +## iOS Home Assistant export + +The iOS build also has an explicit, default-off Home Assistant integration requested for this build. +It writes a fixed set of NOOP sensor states to the user's own Home Assistant REST API using a +user-supplied long-lived token stored in iOS Keychain. It sends the latest daily summary, a recent +heart-rate sample, and the latest strap battery state; it does not install a receiver, read entities, +accept commands, or make strap collection depend on Home Assistant. The **Test connection** action +checks only `GET /api/` and sends no health data. This is a separate, opt-in iOS destination and does +not change the versioned push protocol or its receiver contract. + +Home Assistant's REST state API creates state-backed entities without configuring an integration +entry. They may need a new export after Home Assistant restarts, and are not managed through the +entity registry. Automatic delivery runs after local repository refreshes while the iOS setting is +enabled; **Send latest data now** is also available. This exception does not authorize a hosted NOOP +service, other third-party destinations, or data read-back. + ## Proposing a scope change Scope changes are deliberate, not incidental. If you believe one of the out-of-scope areas should move, diff --git a/project.yml b/project.yml index beac7b618f..e6def84387 100644 --- a/project.yml +++ b/project.yml @@ -21,7 +21,7 @@ settings: # iOS build number (CFBundleVersion). GLOBAL so the iOS app AND its widget extension inherit the # SAME value — they must match or iOS warns "extension version must match parent app" (#416). # macOS sets its own CFBundleVersion on the Strand target and is unaffected by this. - CURRENT_PROJECT_VERSION: "436" + CURRENT_PROJECT_VERSION: "437" SWIFT_VERSION: "5.0" # Emit localizable strings so Xcode auto-extracts every LocalizedStringKey into the # String Catalog on build (the English (US) base entries). @@ -164,6 +164,11 @@ targets: deploymentTarget: "13.0" sources: - StrandTests + # Compile the exact iOS Home Assistant HTTP client into the macOS test bundle so the + # app's GET/POST contract can run against a deterministic fake transport. + - StrandiOS/Integration/HomeAssistantAPIClient.swift + # Keep the sensor/state mapping under test without requiring repository or strap state. + - StrandiOS/Integration/HomeAssistantPayload.swift # Compile the exact cross-target snapshot source into the test bundle so the AltStore App Group # resolver is covered without adding this iOS/widget-only type to the macOS application module. - StrandiOSShared/WidgetSnapshot.swift @@ -300,6 +305,7 @@ targets: # none, so that task would never run in the one case the warning exists for. - $(PRODUCT_BUNDLE_IDENTIFIER).stalebattery NSBluetoothAlwaysUsageDescription: "NOOP connects directly to your WHOOP strap over Bluetooth to read heart rate, R-R intervals, battery, and sensor data locally on your iPhone. Nothing leaves your device." + NSLocalNetworkUsageDescription: "NOOP connects to the Home Assistant and Coach servers you configure on your local network." NSLocationWhenInUseUsageDescription: "NOOP records your route during an outdoor workout to show distance, pace, and a map, and keeps recording while the screen is off. The route stays on your device. Nothing leaves it." NSHealthShareUsageDescription: "NOOP reads your own Apple Health data on-device to compute recovery, strain, and sleep. Nothing leaves your device." NSHealthUpdateUsageDescription: "NOOP writes the metrics it computes from your strap back into Apple Health, on-device and only when you allow it."