在飛速發展的可穿戴技術領域,我們正處於一個十字路口。市場上充斥著各式時尚、功能豐富的裝置,聲稱能夠徹底改變我們對健康和健身的方式。然而,在這些光鮮的外觀和營銷宣傳背後,隱藏著一個令人擔憂的現實:大多數這些裝置是封閉系統,其內部執行被專有程式碼和封閉硬體所掩蓋。作為消費者,我們對這些裝置如何收集、處理及可能共享我們的健康資料一無所知。
這時,Halo 出現了,它是一種旨在讓健康追蹤更加普惠化的開源替代方案。透過這系列文章,我們將引導你從基礎入手,構建並使用完全透明、可定製的可穿戴裝置。
需要說明的是,Halo 的目標並不是在拋光度或功能完整性上與消費級可穿戴裝置競爭。相反,它提供了一種獨特的、動手實踐的方式來理解健康追蹤裝置背後的技術。
我們將使用 Swift 5
來構建對應的 iOS 介面,以及 Python >= 3.10
。由於此專案的程式碼完全 開源,你可以隨時提交合並請求,或者分叉專案以探索全新的方向。
你將需要:
- 獲取 COLMI R02 實際裝置,價格在撰寫時為 11 到 30 美金左右。
- 一個安裝了 Xcode 16 的開發環境,以及可選的 Apple 開發者計劃會員資格。
Python >= 3.10
,並安裝了pandas
、numpy
、torch
當然還有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