Halo 正式開源: 使用可穿戴裝置進行開源健康追蹤

HuggingFace發表於2024-11-22

在飛速發展的可穿戴技術領域,我們正處於一個十字路口。市場上充斥著各式時尚、功能豐富的裝置,聲稱能夠徹底改變我們對健康和健身的方式。然而,在這些光鮮的外觀和營銷宣傳背後,隱藏著一個令人擔憂的現實:大多數這些裝置是封閉系統,其內部執行被專有程式碼和封閉硬體所掩蓋。作為消費者,我們對這些裝置如何收集、處理及可能共享我們的健康資料一無所知。

這時,Halo 出現了,它是一種旨在讓健康追蹤更加普惠化的開源替代方案。透過這系列文章,我們將引導你從基礎入手,構建並使用完全透明、可定製的可穿戴裝置。

需要說明的是,Halo 的目標並不是在拋光度或功能完整性上與消費級可穿戴裝置競爭。相反,它提供了一種獨特的、動手實踐的方式來理解健康追蹤裝置背後的技術。

我們將使用 Swift 5 來構建對應的 iOS 介面,以及 Python >= 3.10。由於此專案的程式碼完全 開源,你可以隨時提交合並請求,或者分叉專案以探索全新的方向。

你將需要:

  • 獲取 COLMI R02 實際裝置,價格在撰寫時為 11 到 30 美金左右。
  • 一個安裝了 Xcode 16 的開發環境,以及可選的 Apple 開發者計劃會員資格。
  • Python >= 3.10,並安裝了 pandasnumpytorch 當然還有 transformers

致謝

此專案基於 Python 倉庫 的程式碼及我的學習成果構建。

免責宣告

作為一名醫生,我有法律義務提醒你:你即將閱讀的內容並不是醫學建議。現在,讓我們開始讓一些可穿戴裝置發出蜂鳴聲吧!

配對戒指

在進入程式碼之前,讓我們先了解藍芽低能耗(BLE)的關鍵規格。BLE 基於一個簡單的客戶端-伺服器模型,使用三個核心概念:中央裝置(Centrals)服務(Services)特徵(Characteristics)。以下是它們的具體介紹:

  • 中央裝置(例如你的 iPhone)負責啟動和管理與外設(例如我們的 COLMI R02 戒指)的連線。戒指透過廣播自身資訊等待手機連線,每次僅支援一臺手機連線。
  • 服務是戒指上相關功能的集合,例如心率監測服務或電池狀態服務。每個服務都有一個唯一識別符號(UUID),客戶端透過它來找到對應服務。
  • 特徵是每個服務中的具體資料點或控制機制。例如,它們可能是隻讀(獲取感測器資料)、只寫(傳送命令)或兩者兼有。有些特徵還能在其值發生變化時自動通知手機,這對於實時健康監測尤為重要。

當手機連線到戒指時,會定位所需的服務,並與特定特徵互動以傳送命令或接收資料。這種結構化的方法不僅確保了通訊效率,還能延長電池使用時間。瞭解了這些基礎知識後,讓我們開始構建吧!

設定 Xcode 專案

建立一個名為 Halo 的新專案,目標平臺為 iOS。組織識別符號建議使用反向域名格式(如 com.example)。本專案中,我們使用 com.FirstNameLastName

接下來,為應用啟用必要的功能。在 Xcode 中,開啟 Signing & Capabilities 選項卡,啟用以下 後臺模式(Background Modes),以確保應用在後臺執行時能夠保持與戒指的連線並處理資料。

然後,我們將使用 Apple 提供的最新框架 AccessorySetupKit,用於將藍芽和 Wi-Fi 配件連線到 iOS 應用。此框架自 iOS 18 推出,替代了傳統的廣泛藍芽許可權請求方式,專注於為使用者明確批准的特定裝置提供訪問許可權。

當使用者嘗試將 COLMI R02 戒指連線到應用時,AccessorySetupKit 會顯示一個系統介面,僅列出相容的附近裝置。使用者選擇裝置後,應用即可與戒指通訊,而無需請求完整的藍芽許可權。這大大提升了使用者隱私,同時簡化了裝置連線的管理流程。

開啟 Info.plist 檔案(可以在左側邊欄中找到,或透過 Project Navigator (⌘1) > Your Target > Info 定位)。新增以下鍵值條目以支援與 COLMI R02 戒指的配對:

  • 新增 NSAccessorySetupKitSupports,型別為 Array,並將 Bluetooth 作為第一個專案。
  • 新增 NSAccessorySetupBluetoothServices,型別為 Array,並將以下 UUID 作為 String 項:
    • 6E40FFF0-B5A3-F393-E0A9-E50E24DCCA9E
    • 0000180A-0000-1000-8000-00805F9B34FB

至此,初步配置完成!🤗

Ring Session Manager 類

接下來,我們將建立一個 RingSessionManager 類,用於管理所有與戒指的通訊。此類的主要職責包括:

  • 掃描附近的戒指
  • 連線到戒指
  • 發現服務和特徵
  • 實現資料讀寫操作

第一步:建立 RingSessionManager

首先建立一個新的 Swift 檔案(⌘N),命名為 RingSessionManager.swift。以下是類的定義以及需要實現的關鍵屬性:

@Observable
class RingSessionManager: NSObject {
    // 追蹤連線狀態
    var peripheralConnected = false
    var pickerDismissed = true

    // 儲存當前連線的戒指
    var currentRing: ASAccessory?
    private var session = ASAccessorySession()

    // 核心藍芽物件
    private var manager: CBCentralManager?
    private var peripheral: CBPeripheral?
}

第二步:發現戒指

戒指透過特定的藍芽服務 UUID 進行廣播。為了找到它,我們需要建立一個 ASDiscoveryDescriptor 物件,指定其藍芽服務的 UUID。以下程式碼完成了這一功能:

private static let ring: ASPickerDisplayItem = {
    let descriptor = ASDiscoveryDescriptor()
    descriptor.bluetoothServiceUUID = CBUUID(string: "6E40FFF0-B5A3-F393-E0A9-E50E24DCCA9E")
    return ASPickerDisplayItem(
        name: "COLMI R02 Ring",
        productImage: UIImage(named: "colmi")!,
        descriptor: descriptor
    )
}()

確保將戒指圖片新增到專案資源目錄中,或者用合適的佔位符替換 UIImage(named: "colmi")!

第三步:顯示戒指選擇器

為了讓使用者選擇戒指,我們呼叫系統內建的裝置選擇器介面:

func presentPicker() {
    session.showPicker(for: [Self.ring]) { error in
        if let error {
            print("Failed to show picker: \(error.localizedDescription)")
        }
    }
}

第四步:處理戒指選擇

當使用者從選擇器中選定裝置後,應用需要處理連線和管理邏輯。以下程式碼實現了事件處理:

private func handleSessionEvent(event: ASAccessoryEvent) {
    switch event.eventType {
    case .accessoryAdded:
        guard let ring = event.accessory else { return }
        saveRing(ring: ring)
    case .activated:
        // 重新連線已配對戒指
        guard let ring = session.accessories.first else { return }
        saveRing(ring: ring)
    case .accessoryRemoved:
        currentRing = nil
        manager = nil
    }
}

第五步:建立連線

完成選擇戒指後,我們需要與其建立藍芽連線:

func connect() {
    guard let manager, manager.state == .poweredOn, let peripheral else { return }
    let options: [String: Any] = [
        CBConnectPeripheralOptionNotifyOnConnectionKey: true,
        CBConnectPeripheralOptionNotifyOnDisconnectionKey: true,
        CBConnectPeripheralOptionStartDelayKey: 1
    ]
    manager.connect(peripheral, options: options)
}

第六步:理解委託方法

RingSessionManager 中,我們實現了兩個關鍵的委託協議,用於管理藍芽通訊過程。

中央管理器委託(CBCentralManagerDelegate)
此委託主要處理藍芽連線的整體狀態。

func centralManagerDidUpdateState(_ central: CBCentralManager) {
    print("Central manager state: \(central.state)")
    switch central.state {
    case .poweredOn:
        if let peripheralUUID = currentRing?.bluetoothIdentifier {
            if let knownPeripheral = central.retrievePeripherals(withIdentifiers: [peripheralUUID]).first {
                print("Found previously connected peripheral")
                peripheral = knownPeripheral
                peripheral?.delegate = self
                connect()
            } else {
                print("Known peripheral not found, starting scan")
            }
        }
    default:
        peripheral = nil
    }
}

當藍芽開啟時,程式會檢查是否有已連線的戒指,並嘗試重新連線。
成功連線後:

func centralManager(_ central: CBCentralManager, didConnect peripheral: CBPeripheral) {
    print("DEBUG: Connected to peripheral: \(peripheral)")
    peripheral.delegate = self
    print("DEBUG: Discovering services...")
    peripheral.discoverServices([CBUUID(string: Self.ringServiceUUID)])
    peripheralConnected = true
}

斷開連線時:

func centralManager(_ central: CBCentralManager, didDisconnectPeripheral peripheral: CBPeripheral, error: (any Error)?) {
    print("Disconnected from peripheral: \(peripheral)")
    peripheralConnected = false
    characteristicsDiscovered = false
}

外設委託(CBPeripheralDelegate)

此委託主要處理與戒指的具體通訊。
首先發現戒指的服務:

func peripheral(_ peripheral: CBPeripheral, didDiscoverServices error: (any Error)?) {
    print("DEBUG: Services discovery callback, error: \(String(describing: error))")
    guard error == nil, let services = peripheral.services else {
        print("DEBUG: No services found or error occurred")
        return
    }
    print("DEBUG: Found \(services.count) services")
    for service in services {
        if service.uuid == CBUUID(string: Self.ringServiceUUID) {
            print("DEBUG: Found ring service, discovering characteristics...")
            peripheral.discoverCharacteristics([
                CBUUID(string: Self.uartRxCharacteristicUUID),
                CBUUID(string: Self.uartTxCharacteristicUUID)
            ], for: service)
        }
    }
}

發現特徵後:

func peripheral(_ peripheral: CBPeripheral, didDiscoverCharacteristicsFor service: CBService, error: Error?) {
    print("DEBUG: Characteristics discovery callback, error: \(String(describing: error))")
    guard error == nil, let characteristics = service.characteristics else {
        print("DEBUG: No characteristics found or error occurred")
        return
    }
    print("DEBUG: Found \(characteristics.count) characteristics")
    for characteristic in characteristics {
        switch characteristic.uuid {
        case CBUUID(string: Self.uartRxCharacteristicUUID):
            print("DEBUG: Found UART RX characteristic")
            self.uartRxCharacteristic = characteristic
        case CBUUID(string: Self.uartTxCharacteristicUUID):
            print("DEBUG: Found UART TX characteristic")
            self.uartTxCharacteristic = characteristic
            peripheral.setNotifyValue(true, for: characteristic)
        default:
            print("DEBUG: Found other characteristic: \(characteristic.uuid)")
        }
    }
    characteristicsDiscovered = true
}

接收資料時:

func peripheral(_ peripheral: CBPeripheral, didUpdateValueFor characteristic: CBCharacteristic, error: Error?) {
    if characteristic.uuid == CBUUID(string: Self.uartTxCharacteristicUUID) {
        if let value = characteristic.value {
            print("Received value: \(value)")
        }
    }
}

傳送命令後:

func peripheral(_ peripheral: CBPeripheral, didWriteValueFor characteristic: CBCharacteristic, error: Error?) {
    if let error = error {
        print("Write to characteristic failed: \(error.localizedDescription)")
    } else {
        print("Write to characteristic successful")
    }
}
完整程式碼

完整的 RingSessionManager 類程式碼如下:

import Foundation
import AccessorySetupKit
import CoreBluetooth
import SwiftUI

@Observable
class RingSessionManager: NSObject {
    var peripheralConnected = false
    var pickerDismissed = true
    
    var currentRing: ASAccessory?
    private var session = ASAccessorySession()
    private var manager: CBCentralManager?
    private var peripheral: CBPeripheral?
    
    private var uartRxCharacteristic: CBCharacteristic?
    private var uartTxCharacteristic: CBCharacteristic?
    
    private static let ringServiceUUID = "6E40FFF0-B5A3-F393-E0A9-E50E24DCCA9E"
    private static let uartRxCharacteristicUUID = "6E400002-B5A3-F393-E0A9-E50E24DCCA9E"
    private static let uartTxCharacteristicUUID = "6E400003-B5A3-F393-E0A9-E50E24DCCA9E"
    
    private static let deviceInfoServiceUUID = "0000180A-0000-1000-8000-00805F9B34FB"
    private static let deviceHardwareUUID = "00002A27-0000-1000-8000-00805F9B34FB"
    private static let deviceFirmwareUUID = "00002A26-0000-1000-8000-00805F9B34FB"
    
    private static let ring: ASPickerDisplayItem = {
        let descriptor = ASDiscoveryDescriptor()
        descriptor.bluetoothServiceUUID = CBUUID(string: ringServiceUUID)
        
        return ASPickerDisplayItem(
            name: "COLMI R02 Ring",
            productImage: UIImage(named: "colmi")!,
            descriptor: descriptor
        )
    }()
    
    private var characteristicsDiscovered = false
    
    override init() {
        super.init()
        self.session.activate(on: DispatchQueue.main, eventHandler: handleSessionEvent(event:))
    }
    
    // MARK: - RingSessionManager actions
    func presentPicker() {
        session.showPicker(for: [Self.ring]) { error in
            if let error {
                print("Failed to show picker due to: \(error.localizedDescription)")
            }
        }
    }
    
    func removeRing() {
        guard let currentRing else { return }
        
        if peripheralConnected {
            disconnect()
        }
        
        session.removeAccessory(currentRing) { _ in
            self.currentRing = nil
            self.manager = nil
        }
    }
    
    func connect() {
        guard
            let manager, manager.state == .poweredOn,
            let peripheral
        else {
            return
        }
        let options: [String: Any] = [
            CBConnectPeripheralOptionNotifyOnConnectionKey: true,
            CBConnectPeripheralOptionNotifyOnDisconnectionKey: true,
            CBConnectPeripheralOptionStartDelayKey: 1
        ]
        manager.connect(peripheral, options: options)
    }
    
    func disconnect() {
        guard let peripheral, let manager else { return }
        manager.cancelPeripheralConnection(peripheral)
    }
    
    // MARK: - ASAccessorySession functions
    private func saveRing(ring: ASAccessory) {
        currentRing = ring
        
        if manager == nil {
            manager = CBCentralManager(delegate: self, queue: nil)
        }
    }
    
    private func handleSessionEvent(event: ASAccessoryEvent) {
        switch event.eventType {
        case .accessoryAdded, .accessoryChanged:
            guard let ring = event.accessory else { return }
            saveRing(ring: ring)
        case .activated:
            guard let ring = session.accessories.first else { return }
            saveRing(ring: ring)
        case .accessoryRemoved:
            self.currentRing = nil
            self.manager = nil
        case .pickerDidPresent:
            pickerDismissed = false
        case .pickerDidDismiss:
            pickerDismissed = true
        default:
            print("Received event type \(event.eventType)")
        }
    }
}

// MARK: - CBCentralManagerDelegate
extension RingSessionManager: CBCentralManagerDelegate {
    func centralManagerDidUpdateState(_ central: CBCentralManager) {
        print("Central manager state: \(central.state)")
        switch central.state {
        case .poweredOn:
            if let peripheralUUID = currentRing?.bluetoothIdentifier {
                if let knownPeripheral = central.retrievePeripherals(withIdentifiers: [peripheralUUID]).first {
                    print("Found previously connected peripheral")
                    peripheral = knownPeripheral
                    peripheral?.delegate = self
                    connect()
                } else {
                    print("Known peripheral not found, starting scan")
                }
            }
        default:
            peripheral = nil
        }
    }
    
    func centralManager(_ central: CBCentralManager, didConnect peripheral: CBPeripheral) {
        print("DEBUG: Connected to peripheral: \(peripheral)")
        peripheral.delegate = self
        print("DEBUG: Discovering services...")
        peripheral.discoverServices([CBUUID(string: Self.ringServiceUUID)])
        
        peripheralConnected = true
    }
    
    func centralManager(_ central: CBCentralManager, didDisconnectPeripheral peripheral: CBPeripheral, error: (any Error)?) {
        print("Disconnected from peripheral: \(peripheral)")
        peripheralConnected = false
        characteristicsDiscovered = false
    }
    
    func centralManager(_ central: CBCentralManager, didFailToConnect peripheral: CBPeripheral, error: (any Error)?) {
        print("Failed to connect to peripheral: \(peripheral), error: \(error.debugDescription)")
    }
}

// MARK: - CBPeripheralDelegate
extension RingSessionManager: CBPeripheralDelegate {
    func peripheral(_ peripheral: CBPeripheral, didDiscoverServices error: (any Error)?) {
        print("DEBUG: Services discovery callback, error: \(String(describing: error))")
        guard error == nil, let services = peripheral.services else {
            print("DEBUG: No services found or error occurred")
            return
        }
        
        print("DEBUG: Found \(services.count) services")
        for service in services {
            if service.uuid == CBUUID(string: Self.ringServiceUUID) {
                print("DEBUG: Found ring service, discovering characteristics...")
                peripheral.discoverCharacteristics([
                    CBUUID(string: Self.uartRxCharacteristicUUID),
                    CBUUID(string: Self.uartTxCharacteristicUUID)
                ], for: service)
            }
        }
    }
    
    func peripheral(_ peripheral: CBPeripheral, didDiscoverCharacteristicsFor service: CBService, error: Error?) {
        print("DEBUG: Characteristics discovery callback, error: \(String(describing: error))")
        guard error == nil, let characteristics = service.characteristics else {
            print("DEBUG: No characteristics found or error occurred")
            return
        }
        
        print("DEBUG: Found \(characteristics.count) characteristics")
        for characteristic in characteristics {
            switch characteristic.uuid {
            case CBUUID(string: Self.uartRxCharacteristicUUID):
                print("DEBUG: Found UART RX characteristic")
                self.uartRxCharacteristic = characteristic
            case CBUUID(string: Self.uartTxCharacteristicUUID):
                print("DEBUG: Found UART TX characteristic")
                self.uartTxCharacteristic = characteristic
                peripheral.setNotifyValue(true, for: characteristic)
            default:
                print("DEBUG: Found other characteristic: \(characteristic.uuid)")
            }
        }
        characteristicsDiscovered = true
    }
    
    func peripheral(_ peripheral: CBPeripheral, didUpdateValueFor characteristic: CBCharacteristic, error: Error?) {
        if characteristic.uuid == CBUUID(string: Self.uartTxCharacteristicUUID) {
            if let value = characteristic.value {
                print("Received value: \(value)")
            }
        }
    }
    
    func peripheral(_ peripheral: CBPeripheral, didWriteValueFor characteristic: CBCharacteristic, error: Error?) {
        if let error = error {
            print("Write to characteristic failed: \(error.localizedDescription)")
        } else {
            print("Write to characteristic successful")
        }
    }
}

最後一步:將其應用到我們的應用程式中

ContentView.swift 中貼上以下程式碼,作為主介面的一部分:

import SwiftUI
import AccessorySetupKit

struct ContentView: View {
    @State var ringSessionManager = RingSessionManager()
    var body: some View {
        List {
            Section("MY DEVICE", content: {
                if ringSessionManager.pickerDismissed, let currentRing = ringSessionManager.currentRing {
                    makeRingView(ring: currentRing)
                } else {
                    Button {
                        ringSessionManager.presentPicker()
                    } label: {
                        Text("Add Ring")
                            .frame(maxWidth: .infinity)
                            .font(Font.headline.weight(.semibold))
                    }
                }
            })
        }.listStyle(.insetGrouped)
    }
    
    @ViewBuilder
    private func makeRingView(ring: ASAccessory) -> some View {
        HStack {
            Image("colmi")
                .resizable()
                .aspectRatio(contentMode: .fit)
                .frame(height: 70)
            VStack(alignment: .leading) {
                Text(ring.displayName)
                    .font(Font.headline.weight(.semibold))
            }
        }
    }
}

#Preview {
    ContentView()
}

如果一切配置正確,你現在可以構建並執行應用。當點選“Add Ring”按鈕時,將彈出一個介面,顯示附近的相容裝置(包括 COLMI R02 戒指)。選擇裝置後,應用即可完成連線。🎉

連線演示

在後續的文章中,我們將進一步探索如何與戒指互動,包括讀取電池電量、獲取感測器資料(如 PPG 和加速度計),並基於這些資料開發實時心率監測、活動追蹤及睡眠檢測功能。敬請期待!

英文原文: https://hf.co/blog/cyrilzakka/halo-introduction

原文作者: Cyril, ML Researcher, Health AI Lead @ Hugging Face

譯者: Lu Cheng, Hugging Face Fellow

相關文章