在飛速發(fā)展的可穿戴技術(shù)領(lǐng)域,我們正處于一個十字路口——市場上充斥著各式時尚、功能豐富的設(shè)備,聲稱能夠徹底改變我們對健康和健身的方式。
然而,在這些光鮮的外觀和營銷宣傳背后,隱藏著一個令人擔(dān)憂的現(xiàn)實:大多數(shù)這些設(shè)備是封閉系統(tǒng),其內(nèi)部運行被專有代碼和封閉硬件所掩蓋。作為消費者,我們對這些設(shè)備如何收集、處理及可能共享我們的健康數(shù)據(jù)一無所知。
這時,Halo 出現(xiàn)了,它是一種旨在讓健康追蹤更加普惠化的開源替代方案。通過這系列文章,我們將引導(dǎo)你從基礎(chǔ)入手,構(gòu)建并使用完全透明、可定制的可穿戴設(shè)備。
需要說明的是,Halo 的目標(biāo)并不是在外觀或功能完整性上與消費級可穿戴設(shè)備競爭。相反,它提供了一種獨特的、動手實踐的方式來理解健康追蹤設(shè)備背后的技術(shù)。
我們將使用 Swift 5
來構(gòu)建對應(yīng)的 iOS 界面,Python >= 3.10
。由于此項目的代碼完全開源 ,你可以隨時提交 PR 拉取請求,或者 Fork 分叉項目以探索全新的方向。
開源 https://github.com/cyrilzakka/Halo-iOS 你將需要:
獲取COLMI R02 實體設(shè)備,價格在撰寫時為 11 到 30 美金左右。https://www./item/3256806445134241.html?gatewayAdapt=glo2usa4itemAdapt 一個安裝了 Xcode 16 的開發(fā)環(huán)境,以及可選的 Apple 開發(fā)者計劃會員資格。 Python >= 3.10
,并安裝了 pandas
、numpy
、torch
當(dāng)然還有 transformers
。致謝
此項目基于Python 倉庫 的代碼及我的學(xué)習(xí)成果構(gòu)建。
Python 倉庫 https://tahnok./colmi_r02_client/ 免責(zé)聲明
作為一名醫(yī)生,我有法律義務(wù)提醒你:你即將閱讀的內(nèi)容并不是醫(yī)學(xué)建議。現(xiàn)在,讓我們開始讓一些可穿戴設(shè)備發(fā)出蜂鳴聲吧!
配對戒指 在進(jìn)入代碼之前,讓我們先了解藍(lán)牙低能耗 (BLE) 的關(guān)鍵規(guī)格。BLE 基于一個簡單的客戶端-服務(wù)器模型,使用三個核心概念:中央設(shè)備 (Centrals) 、服務(wù) (Services) 和 **特征 (Characteristics)**。以下是它們的具體介紹:
中央設(shè)備 (例如你的 iPhone) 負(fù)責(zé)啟動和管理與外設(shè) (例如我們的 COLMI R02 戒指) 的連接。戒指通過廣播自身信息等待手機(jī)連接,每次僅支持一臺手機(jī)連接。服務(wù) 是戒指上相關(guān)功能的集合,例如心率監(jiān)測服務(wù)或電池狀態(tài)服務(wù)。每個服務(wù)都有一個唯一標(biāo)識符 (UUID) ,客戶端通過它來找到對應(yīng)服務(wù)。特征 是每個服務(wù)中的具體數(shù)據(jù)點或控制機(jī)制。例如,它們可能是只讀 (獲取傳感器數(shù)據(jù)) 、只寫 (發(fā)送命令) 或兩者兼有。有些特征還能在其值發(fā)生變化時自動通知手機(jī),這對于實時健康監(jiān)測尤為重要。當(dāng)手機(jī)連接到戒指時,會定位所需的服務(wù),并與特定特征交互以發(fā)送命令或接收數(shù)據(jù)。這種結(jié)構(gòu)化的方法不僅確保了通信效率,還能延長電池使用時間。了解了這些基礎(chǔ)知識后,讓我們開始構(gòu)建吧!
設(shè)置 Xcode 項目 創(chuàng)建一個名為 Halo
的新項目,目標(biāo)平臺為 iOS
。組織標(biāo)識符建議使用反向域名格式 (如 com.example
) 。本項目中,我們使用 com.FirstNameLastName
。
接下來,為應(yīng)用啟用必要的功能。在 Xcode 中,打開 Signing & Capabilities
選項卡,啟用以下 后臺模式 (Background Modes) ,以確保應(yīng)用在后臺運行時能夠保持與戒指的連接并處理數(shù)據(jù)。
然后,我們將使用 Apple 提供的最新框架AccessorySetupKit ,用于將藍(lán)牙和 Wi-Fi 配件連接到 iOS 應(yīng)用。此框架自 iOS 18 推出,替代了傳統(tǒng)的廣泛藍(lán)牙權(quán)限請求方式,專注于為用戶明確批準(zhǔn)的特定設(shè)備提供訪問權(quán)限。
AccessorySetupKit https://developer.apple.com/documentation/accessorysetupkit/ 當(dāng)用戶嘗試將 COLMI R02 戒指連接到應(yīng)用時,AccessorySetupKit
會顯示一個系統(tǒng)界面,僅列出兼容的附近設(shè)備。用戶選擇設(shè)備后,應(yīng)用即可與戒指通信,而無需請求完整的藍(lán)牙權(quán)限。這大大提升了用戶隱私,同時簡化了設(shè)備連接的管理流程。
打開 Info.plist
文件 (可以在左側(cè)邊欄中找到,或通過 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 類 接下來,我們將創(chuàng)建一個 RingSessionManager
類,用于管理所有與戒指的通信。此類的主要職責(zé)包括:
第一步:創(chuàng)建 RingSessionManager 首先創(chuàng)建一個新的 Swift 文件 (?N) ,命名為 RingSessionManager.swift
。以下是類的定義以及需要實現(xiàn)的關(guān)鍵屬性:
@Observable class RingSessionManager : NSObject { // 追蹤連接狀態(tài) var peripheralConnected = false var pickerDismissed = true // 存儲當(dāng)前連接的戒指 var currentRing: ASAccessory? private var session = ASAccessorySession () // 核心藍(lán)牙對象 private var manager: CBCentralManager? private var peripheral: CBPeripheral? }
第二步:發(fā)現(xiàn)戒指 戒指通過特定的藍(lán)牙服務(wù) UUID 進(jìn)行廣播。為了找到它,我們需要創(chuàng)建一個 ASDiscoveryDescriptor
對象,指定其藍(lán)牙服務(wù)的 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')!
。
第三步:顯示戒指選擇器 為了讓用戶選擇戒指,我們調(diào)用系統(tǒng)內(nèi)置的設(shè)備選擇器界面:
func presentPicker () { session.showPicker(for : [Self .ring]) { error in if let error { print ('Failed to show picker: \(error.localizedDescription)' ) } } }
第四步:處理戒指選擇 當(dāng)用戶從選擇器中選定設(shè)備后,應(yīng)用需要處理連接和管理邏輯。以下代碼實現(xiàn)了事件處理:
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 } }
第五步:建立連接 完成選擇戒指后,我們需要與其建立藍(lán)牙連接:
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
中,我們實現(xiàn)了兩個關(guān)鍵的委托協(xié)議,用于管理藍(lán)牙通信過程。
中央管理器委托 (CBCentralManagerDelegate) 此委托主要處理藍(lán)牙連接的整體狀態(tài)。
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 } }
當(dāng)藍(lán)牙開啟時,程序會檢查是否有已連接的戒指,并嘗試重新連接。 成功連接后:
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 }
外設(shè)委托 (CBPeripheralDelegate)
此委托主要處理與戒指的具體通信。 首先發(fā)現(xiàn)戒指的服務(wù):
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) } } }
發(fā)現(xiàn)特征后:
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 }
接收數(shù)據(jù)時:
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)' ) } } }
發(fā)送命令后:
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 Foundationimport AccessorySetupKitimport CoreBluetoothimport 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' ) } } }
最后一步:將其應(yīng)用到我們的應(yīng)用程序中 在 ContentView.swift
中粘貼以下代碼,作為主界面的一部分:
import SwiftUIimport AccessorySetupKitstruct 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 () }
如果一切配置正確,你現(xiàn)在可以構(gòu)建并運行應(yīng)用。當(dāng)點擊“Add Ring”按鈕時,將彈出一個界面,顯示附近的兼容設(shè)備 (包括 COLMI R02 戒指) 。選擇設(shè)備后,應(yīng)用即可完成連接。??
連接演示 在后續(xù)的文章中,我們將進(jìn)一步探索如何與戒指交互,包括讀取電池電量、獲取傳感器數(shù)據(jù) (如 PPG 和加速度計) ,并基于這些數(shù)據(jù)開發(fā)實時心率監(jiān)測、活動追蹤及睡眠檢測功能。敬請期待!
英文原文:https:///blog/cyrilzakka/halo-introduction
原文作者: Cyril, ML Researcher, Health AI Lead @ Hugging Face
譯者: Lu Cheng, Hugging Face Fellow