feat: implement Haptika PPG and Watch V1
This commit is contained in:
@@ -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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user