PulseMonitor.swift 9.04 KB
//
//  PulseMonitor.swift
//  hippo-watch Watch App
//
//  Created by shihao on 2025/10/22.
//

import Foundation
import HealthKit
import SwiftUI
import Combine

class PulseMonitor: NSObject, ObservableObject {
    @Published var progress: Double = 0
    @Published var pulseType: PulseType?
    
    private let healthStore = HKHealthStore()
    private var session: HKWorkoutSession?
    private var builder: HKLiveWorkoutBuilder?
    private var heartRateQuery: HKAnchoredObjectQuery?
    private var progressTimer: Timer?
    


    var onHeartRateUpdate: ((Double) -> Void)?
    var onOxygenUpdate: ((Double) -> Void)?
    
    // MARK: - Heart Rate Buffer
    private var heartRateSamplesBuffer: [Double] = []
    
    // MARK: - 脉象表
    struct PulsePattern {
        let heartRateRange: ClosedRange<Double>
        let oxygenRange: ClosedRange<Double>
        let pulseType: PulseType
    }
    
    private let pulsePatterns: [PulsePattern] = [
        PulsePattern(heartRateRange: 60...80, oxygenRange: 86...94, pulseType: .fu),
        PulsePattern(heartRateRange: 40...60, oxygenRange: 88...92, pulseType: .chen),
        PulsePattern(heartRateRange: 40...60, oxygenRange: 92...98, pulseType: .chi),
        PulsePattern(heartRateRange: 80...120, oxygenRange: 90...94, pulseType: .shu),
        PulsePattern(heartRateRange: 80...100, oxygenRange: 98...100, pulseType: .hua),
        PulsePattern(heartRateRange: 80...120, oxygenRange: 86...90, pulseType: .se),
        PulsePattern(heartRateRange: 80...100, oxygenRange: 94...98, pulseType: .xu),
        PulsePattern(heartRateRange: 60...80, oxygenRange: 94...100, pulseType: .pin)
    ]
    
    private func matchPulsePattern(heartRate: Double, oxygen: Double) -> PulseType? {
        return pulsePatterns.first { $0.heartRateRange.contains(heartRate) && $0.oxygenRange.contains(oxygen) }?.pulseType
    }

    func startMonitoring() {
        let configuration = HKWorkoutConfiguration()
        configuration.activityType = .other
        configuration.locationType = .indoor

        do {
            session = try HKWorkoutSession(healthStore: healthStore, configuration: configuration)
            builder = session?.associatedWorkoutBuilder()
            session?.delegate = self
            builder?.dataSource = HKLiveWorkoutDataSource(healthStore: healthStore, workoutConfiguration: configuration)

            session?.startActivity(with: Date())
            builder?.beginCollection(withStart: Date()) { success, error in
                if let error = error {
                    print("💥 Failed to start collection: \(error.localizedDescription)")
                } else {
                    print("✅ Workout session started")
                    self.startHeartRateQuery()
                }
            }
        } catch {
            print("❌ Failed to create session: \(error.localizedDescription)")
        }
    }

    func stopMonitoring() {
        session?.end()
        builder?.endCollection(withEnd: Date()) { success, error in
            self.builder?.finishWorkout { workout, error in
                print("✅ Workout ended")
            }
        }
        if let query = heartRateQuery {
            healthStore.stop(query)
        }
    }

    private func startHeartRateQuery() {
        let type = HKQuantityType.quantityType(forIdentifier: .heartRate)!
        let predicate = HKQuery.predicateForSamples(withStart: Date(), end: nil, options: .strictStartDate)

        heartRateQuery = HKAnchoredObjectQuery(type: type, predicate: predicate, anchor: nil, limit: HKObjectQueryNoLimit) { _, samples, _, _, _ in
            self.handleHeartRateSamples(samples)
        }

        heartRateQuery?.updateHandler = { _, samples, _, _, _ in
            self.handleHeartRateSamples(samples)
        }

        if let query = heartRateQuery {
            healthStore.execute(query)
        }
    }

    private func handleHeartRateSamples(_ samples: [HKSample]?) {
        guard let heartRateSamples = samples as? [HKQuantitySample] else { return }
        for sample in heartRateSamples {
            let bpm = sample.quantity.doubleValue(for: HKUnit(from: "count/min"))
            heartRateSamplesBuffer.append(bpm)
            print("❤️ 心率: \(Int(bpm)) bpm")
        }
    }
    
    // MARK: - Get Latest Oxygen
    private func getLatestOxygen(completion: @escaping (Double?) -> Void) {
        guard let type = HKQuantityType.quantityType(forIdentifier: .oxygenSaturation) else {
            completion(nil)
            return
        }
        
        let sort = NSSortDescriptor(key: HKSampleSortIdentifierStartDate, ascending: false)
        let query = HKSampleQuery(sampleType: type, predicate: nil, limit: 1, sortDescriptors: [sort]) { _, samples, _ in
            guard let sample = samples?.first as? HKQuantitySample else {
                completion(nil)
                return
            }
            let value = sample.quantity.doubleValue(for: HKUnit.percent()) * 100
            completion(value)
        }
        healthStore.execute(query)
    }
    
    private func getLatestHeartRate() async -> Double? {
        return await withCheckedContinuation { continuation in
            guard let type = HKQuantityType.quantityType(forIdentifier: .heartRate) else {
                continuation.resume(returning: nil)
                return
            }

            let sort = NSSortDescriptor(key: HKSampleSortIdentifierStartDate, ascending: false)
            let query = HKSampleQuery(sampleType: type,
                                      predicate: nil,
                                      limit: 1,
                                      sortDescriptors: [sort]) { _, samples, _ in

                guard let sample = samples?.first as? HKQuantitySample else {
                    continuation.resume(returning: nil)
                    return
                }

                let bpm = sample.quantity.doubleValue(for: HKUnit(from: "count/min"))
                let timeDiff = Date().timeIntervalSince(sample.startDate)

                // ✅ 只接受两分钟内的心率值
                continuation.resume(returning: timeDiff <= 120 ? bpm : nil)
            }

            healthStore.execute(query)
        }
    }
    
    // MARK: - Start Diagnosis
    func startDiagnosis(duration: TimeInterval = 11,
                        completion: @escaping (PulseType?) -> Void) {
        heartRateSamplesBuffer.removeAll()
        startMonitoring()
        
        // 进度计时器,每 0.1 秒更新一次 progress
        let interval = 0.2
        let steps = duration / interval
        var currentStep = 0
        
        progressTimer?.invalidate()
        progressTimer = Timer.scheduledTimer(withTimeInterval: interval, repeats: true) { [weak self] timer in
            guard let self = self else { return }
            currentStep += 1
            withAnimation {
                self.progress = min(Double(currentStep) / steps, 1)
            }
            if self.progress >= 1 {
                timer.invalidate()
            }
        }
        
        DispatchQueue.main.asyncAfter(deadline: .now() + duration) { [weak self] in
            guard let self = self else { return }
            self.stopMonitoring()
            
            let avgHeartRate = self.heartRateSamplesBuffer.average()
            self.getLatestOxygen { oxygen in
                Task {
                    var finalHR = avgHeartRate
                    
                    // ✅ 只有 avgHeartRate == 0 时才调用兜底
                    if finalHR == 0 {
                        if let hr = await self.getLatestHeartRate() {
                            finalHR = hr
                        }
                    }
                    
                    DispatchQueue.main.async {
                        if oxygen == nil && avgHeartRate == 0 {
                            completion(nil)
                            return
                        }
                        
                        guard let oxygen else {
                            self.pulseType = .pin
                            completion(.pin)
                            return
                        }
                        guard finalHR != 0 else {
                            completion(nil)
                            return
                        }
                        
                        let pulseType = self.matchPulsePattern(heartRate: finalHR, oxygen: oxygen)
                        self.pulseType = pulseType
                        print("诊脉结果:\(pulseType), avgHeartRate: \(finalHR), oxygen:\(oxygen)")
                        completion(pulseType)
                    }
                }
            }
        }
    }
}


extension PulseMonitor: HKWorkoutSessionDelegate {
    func workoutSession(_ workoutSession: HKWorkoutSession, didChangeTo toState: HKWorkoutSessionState, from fromState: HKWorkoutSessionState, date: Date) {
        print("🔄 Workout state: \(fromState.rawValue)\(toState.rawValue)")
    }

    func workoutSession(_ workoutSession: HKWorkoutSession, didFailWithError error: Error) {
        print("💥 Workout error: \(error.localizedDescription)")
    }
}