Skip to content
Open
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
8 changes: 5 additions & 3 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
133 changes: 133 additions & 0 deletions StrandTests/HomeAssistantAPIClientTests.swift
Original file line number Diff line number Diff line change
@@ -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)
}
}
21 changes: 20 additions & 1 deletion StrandiOS/App/RootTabView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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<Void, Never>?
/// 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?
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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)
}
Expand Down Expand Up @@ -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 {
Expand All @@ -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()
Expand Down
1 change: 1 addition & 0 deletions StrandiOS/App/StrandiOSApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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())
Expand Down
106 changes: 106 additions & 0 deletions StrandiOS/Integration/HomeAssistantAPIClient.swift
Original file line number Diff line number Diff line change
@@ -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)
}
}
Loading
Loading