feat: implement Haptika PPG and Watch V1

This commit is contained in:
Sacha
2026-07-31 16:19:42 +02:00
parent 6b7d4e0728
commit b9930b7378
249 changed files with 46475 additions and 122 deletions
+377
View File
@@ -0,0 +1,377 @@
import Foundation
public enum MeasurementSource: String, Codable, CaseIterable, Sendable {
case iphoneCameraPPG
case appleWatchOptical
}
public struct WatchHeartRateSampleV1: Codable, Equatable, Sendable, Identifiable {
public var id: UUID
public var date: Date
public var bpm: Double
public init(id: UUID = UUID(), date: Date, bpm: Double) {
self.id = id
self.date = date
self.bpm = bpm
}
}
public struct WatchDeviceIdentityV1: Codable, Equatable, Sendable {
public var id: String
public var name: String
public var model: String?
public var systemVersion: String?
public init(id: String, name: String, model: String?, systemVersion: String?) {
self.id = id
self.name = name
self.model = model
self.systemVersion = systemVersion
}
private enum CodingKeys: String, CodingKey { case id, name, model, systemVersion }
public func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(id, forKey: .id)
try container.encode(name, forKey: .name)
if let model { try container.encode(model, forKey: .model) } else { try container.encodeNil(forKey: .model) }
if let systemVersion { try container.encode(systemVersion, forKey: .systemVersion) } else { try container.encodeNil(forKey: .systemVersion) }
}
}
public enum WatchMeasurementPosture: String, Codable, CaseIterable, Sendable {
case seated
case standing
case lying
case unknown
}
public struct WatchMeasurementContextV1: Codable, Equatable, Sendable {
public var posture: WatchMeasurementPosture
public var isAtRest: Bool
public var recentCaffeine: Bool
public var recentMeal: Bool
public var recentExercise: Bool
public var note: String?
public init(
posture: WatchMeasurementPosture = .unknown,
isAtRest: Bool = true,
recentCaffeine: Bool = false,
recentMeal: Bool = false,
recentExercise: Bool = false,
note: String? = nil
) {
self.posture = posture
self.isAtRest = isAtRest
self.recentCaffeine = recentCaffeine
self.recentMeal = recentMeal
self.recentExercise = recentExercise
self.note = note
}
public var isBaselineComparable: Bool {
isAtRest && !recentCaffeine && !recentMeal && !recentExercise
}
}
public struct WatchMeasurementSummaryV1: Codable, Equatable, Sendable, Identifiable {
public static let schemaVersion = 1
public var id: UUID
public var updatedAt: Date
public var startedAt: Date
public var endedAt: Date
public var source: MeasurementSource
public var device: WatchDeviceIdentityV1
public var currentBPM: Double?
public var averageBPM: Double?
public var minimumBPM: Double?
public var maximumBPM: Double?
public var samples: [WatchHeartRateSampleV1]
public var coverageSeconds: TimeInterval
public var quality: Double
public var isValid: Bool
public var invalidReasons: [WatchMeasurementInvalidReason]
public var context: WatchMeasurementContextV1
public var feeling: Int?
public var feelingRecordedAt: Date?
public init(
id: UUID = UUID(),
updatedAt: Date = Date(),
startedAt: Date,
endedAt: Date,
source: MeasurementSource = .appleWatchOptical,
device: WatchDeviceIdentityV1,
currentBPM: Double?,
averageBPM: Double?,
minimumBPM: Double?,
maximumBPM: Double?,
samples: [WatchHeartRateSampleV1],
coverageSeconds: TimeInterval,
quality: Double,
isValid: Bool,
invalidReasons: [WatchMeasurementInvalidReason] = [],
context: WatchMeasurementContextV1 = .init(),
feeling: Int? = nil,
feelingRecordedAt: Date? = nil
) {
self.id = id
self.updatedAt = updatedAt
self.startedAt = startedAt
self.endedAt = endedAt
self.source = source
self.device = device
self.currentBPM = currentBPM
self.averageBPM = averageBPM
self.minimumBPM = minimumBPM
self.maximumBPM = maximumBPM
self.samples = samples
self.coverageSeconds = coverageSeconds
self.quality = quality
self.isValid = isValid
self.invalidReasons = invalidReasons
self.context = context
self.feeling = feeling
self.feelingRecordedAt = feelingRecordedAt
}
private enum CodingKeys: String, CodingKey {
case schemaVersion, id, updatedAt, startedAt, endedAt, source, device
case currentBpm, averageBpm, minimumBpm, maximumBpm, samples, coverage, quality
case valid, invalidReason, context, feeling
}
private struct WireSample: Codable {
let offsetSeconds: TimeInterval
let bpm: Double
}
private struct WireContext: Codable {
let atRest: Bool
let posture: String?
let recentExercise: Bool?
let recentCaffeine: Bool?
let recentMeal: Bool?
}
private struct WireFeeling: Codable {
let score: Int
let recordedAt: Date
}
public func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(Self.schemaVersion, forKey: .schemaVersion)
try container.encode(id, forKey: .id)
try container.encode(updatedAt, forKey: .updatedAt)
try container.encode(startedAt, forKey: .startedAt)
try container.encode(endedAt, forKey: .endedAt)
try container.encode(source, forKey: .source)
try container.encode(device, forKey: .device)
if let currentBPM { try container.encode(currentBPM, forKey: .currentBpm) }
else { try container.encodeNil(forKey: .currentBpm) }
try container.encodeIfPresent(averageBPM, forKey: .averageBpm)
try container.encodeIfPresent(minimumBPM, forKey: .minimumBpm)
try container.encodeIfPresent(maximumBPM, forKey: .maximumBpm)
try container.encode(samples.map { WireSample(offsetSeconds: max(0, $0.date.timeIntervalSince(startedAt)), bpm: $0.bpm) }, forKey: .samples)
try container.encode(min(1, max(0, coverageSeconds / 60)), forKey: .coverage)
try container.encode(quality, forKey: .quality)
try container.encode(isValid, forKey: .valid)
if let invalidReason = invalidReasons.first?.rawValue { try container.encode(invalidReason, forKey: .invalidReason) }
else { try container.encodeNil(forKey: .invalidReason) }
try container.encode(WireContext(
atRest: context.isAtRest,
posture: context.posture == .unknown ? nil : context.posture.rawValue,
recentExercise: context.recentExercise,
recentCaffeine: context.recentCaffeine,
recentMeal: context.recentMeal
), forKey: .context)
if let feeling {
try container.encode(WireFeeling(score: feeling, recordedAt: feelingRecordedAt ?? updatedAt), forKey: .feeling)
} else {
try container.encodeNil(forKey: .feeling)
}
}
public init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
guard try container.decode(Int.self, forKey: .schemaVersion) == Self.schemaVersion else {
throw DecodingError.dataCorruptedError(forKey: .schemaVersion, in: container, debugDescription: "Unsupported Watch measurement schema")
}
id = try container.decode(UUID.self, forKey: .id)
updatedAt = try container.decode(Date.self, forKey: .updatedAt)
startedAt = try container.decode(Date.self, forKey: .startedAt)
endedAt = try container.decode(Date.self, forKey: .endedAt)
source = try container.decode(MeasurementSource.self, forKey: .source)
device = try container.decode(WatchDeviceIdentityV1.self, forKey: .device)
currentBPM = try container.decodeIfPresent(Double.self, forKey: .currentBpm)
averageBPM = try container.decodeIfPresent(Double.self, forKey: .averageBpm)
minimumBPM = try container.decodeIfPresent(Double.self, forKey: .minimumBpm)
maximumBPM = try container.decodeIfPresent(Double.self, forKey: .maximumBpm)
let wireSamples = try container.decode([WireSample].self, forKey: .samples)
let decodedStartedAt = startedAt
samples = wireSamples.map { .init(date: decodedStartedAt.addingTimeInterval($0.offsetSeconds), bpm: $0.bpm) }
coverageSeconds = try container.decode(Double.self, forKey: .coverage) * 60
quality = try container.decode(Double.self, forKey: .quality)
isValid = try container.decode(Bool.self, forKey: .valid)
invalidReasons = (try container.decodeIfPresent(String.self, forKey: .invalidReason))
.flatMap(WatchMeasurementInvalidReason.init(rawValue:)).map { [$0] } ?? []
let wireContext = try container.decode(WireContext.self, forKey: .context)
context = .init(
posture: wireContext.posture.flatMap(WatchMeasurementPosture.init(rawValue:)) ?? .unknown,
isAtRest: wireContext.atRest,
recentCaffeine: wireContext.recentCaffeine ?? false,
recentMeal: wireContext.recentMeal ?? false,
recentExercise: wireContext.recentExercise ?? false
)
let wireFeeling = try container.decodeIfPresent(WireFeeling.self, forKey: .feeling)
feeling = wireFeeling?.score
feelingRecordedAt = wireFeeling?.recordedAt
}
}
public enum WatchRegulationSessionState: String, Codable, Sendable {
case inProgress
case awaitingAfter
case completed
case cancelled
}
public struct WatchRegulationSessionV1: Codable, Equatable, Sendable, Identifiable {
public static let currentSchemaVersion = 1
public let schemaVersion = 1
public var id: UUID
public var protocolID: String
public var protocolVersion: Int = 1
public var startedAt: Date
public var endedAt: Date
public var durationSeconds: TimeInterval
public var state: WatchRegulationSessionState
public var beforeMeasurementID: UUID?
public var afterMeasurementID: UUID?
public var feelingBefore: Int?
public var feelingAfter: Int?
public var healthKitWorkoutID: UUID?
public var updatedAt: Date
public init(
id: UUID = UUID(),
protocolID: String,
protocolVersion: Int = 1,
startedAt: Date,
endedAt: Date,
durationSeconds: TimeInterval,
state: WatchRegulationSessionState,
beforeMeasurementID: UUID? = nil,
afterMeasurementID: UUID? = nil,
feelingBefore: Int? = nil,
feelingAfter: Int? = nil,
healthKitWorkoutID: UUID? = nil,
updatedAt: Date = Date()
) {
self.id = id
self.protocolID = protocolID
self.protocolVersion = protocolVersion
self.startedAt = startedAt
self.endedAt = endedAt
self.durationSeconds = durationSeconds
self.state = state
self.beforeMeasurementID = beforeMeasurementID
self.afterMeasurementID = afterMeasurementID
self.feelingBefore = feelingBefore
self.feelingAfter = feelingAfter
self.healthKitWorkoutID = healthKitWorkoutID
self.updatedAt = updatedAt
}
private enum CodingKeys: String, CodingKey {
case schemaVersion, id, protocolID = "protocolId", startedAt, endedAt, durationSeconds, state
case beforeMeasurementID = "beforeMeasurementId", afterMeasurementID = "afterMeasurementId"
case feelingBefore, feelingAfter, healthKitWorkoutID = "workoutUUID", updatedAt
}
public func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(schemaVersion, forKey: .schemaVersion)
try container.encode(id, forKey: .id)
try container.encode(protocolID, forKey: .protocolID)
try container.encode(startedAt, forKey: .startedAt)
try container.encode(endedAt, forKey: .endedAt)
try container.encode(durationSeconds, forKey: .durationSeconds)
try container.encode(state, forKey: .state)
try container.encode(updatedAt, forKey: .updatedAt)
if let beforeMeasurementID { try container.encode(beforeMeasurementID, forKey: .beforeMeasurementID) }
else { try container.encodeNil(forKey: .beforeMeasurementID) }
if let afterMeasurementID { try container.encode(afterMeasurementID, forKey: .afterMeasurementID) }
else { try container.encodeNil(forKey: .afterMeasurementID) }
if let feelingBefore { try container.encode(feelingBefore, forKey: .feelingBefore) }
else { try container.encodeNil(forKey: .feelingBefore) }
if let feelingAfter { try container.encode(feelingAfter, forKey: .feelingAfter) }
else { try container.encodeNil(forKey: .feelingAfter) }
if let healthKitWorkoutID { try container.encode(healthKitWorkoutID, forKey: .healthKitWorkoutID) }
else { try container.encodeNil(forKey: .healthKitWorkoutID) }
}
public init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
guard try container.decode(Int.self, forKey: .schemaVersion) == 1 else {
throw DecodingError.dataCorruptedError(forKey: .schemaVersion, in: container, debugDescription: "Unsupported Watch session schema")
}
id = try container.decode(UUID.self, forKey: .id)
protocolID = try container.decode(String.self, forKey: .protocolID)
protocolVersion = 1
startedAt = try container.decode(Date.self, forKey: .startedAt)
endedAt = try container.decode(Date.self, forKey: .endedAt)
durationSeconds = try container.decode(TimeInterval.self, forKey: .durationSeconds)
state = try container.decode(WatchRegulationSessionState.self, forKey: .state)
beforeMeasurementID = try container.decodeIfPresent(UUID.self, forKey: .beforeMeasurementID)
afterMeasurementID = try container.decodeIfPresent(UUID.self, forKey: .afterMeasurementID)
feelingBefore = try container.decodeIfPresent(Int.self, forKey: .feelingBefore)
feelingAfter = try container.decodeIfPresent(Int.self, forKey: .feelingAfter)
healthKitWorkoutID = try container.decodeIfPresent(UUID.self, forKey: .healthKitWorkoutID)
updatedAt = try container.decode(Date.self, forKey: .updatedAt)
}
}
public struct WatchDeletionTombstoneV1: Codable, Equatable, Sendable, Identifiable {
public let schemaVersion = 1
public let id: UUID = UUID()
public let entityID: UUID
public let entityKind: String
public let deletedAt: Date
public let updatedAt: Date
public init(entityID: UUID, entityKind: String, deletedAt: Date = Date(), updatedAt: Date = Date()) {
self.entityID = entityID
self.entityKind = entityKind
self.deletedAt = deletedAt
self.updatedAt = updatedAt
}
private enum CodingKeys: String, CodingKey {
case schemaVersion, entityID = "entityId", entityKind, deletedAt, updatedAt
}
}
public struct WatchPreferencesV1: Codable, Equatable, Sendable {
public var schemaVersion = 1
public var saveMindAndBodyWorkouts: Bool
public var protocolCatalogVersion: String
public var updatedAt: Date
public init(
saveMindAndBodyWorkouts: Bool = true,
protocolCatalogVersion: String = "1.0.0",
updatedAt: Date = Date()
) {
self.saveMindAndBodyWorkouts = saveMindAndBodyWorkouts
self.protocolCatalogVersion = protocolCatalogVersion
self.updatedAt = updatedAt
}
}