Halo:使用可穿戴设备进行开源健康追踪

社区文章 发布于 2024 年 11 月 19 日

image/png 在可穿戴技术迅速发展的格局中,我们发现自己正处在一个十字路口。市场上充斥着时尚、功能丰富的设备,它们承诺彻底改变我们对待健康和健身的方式。然而,在这些光鲜的外表和营销炒作之下,隐藏着一个令人不安的现实:大多数设备都是黑盒子,其内部工作原理被专有代码和闭源硬件所笼罩。作为消费者,我们对自己的私人健康数据是如何收集、处理以及可能共享的一无所知。

Halo 应运而生,它是一个旨在民主化健康追踪的开源替代方案。本系列文章将作为您的入门指南,教您如何构建和使用一个完全透明、可定制的可穿戴设备。

重要的是,Halo 并非旨在与消费级可穿戴设备在完善性或功能完整性方面竞争。相反,它提供了一种独特、实践的方法来理解健康追踪设备背后的技术。

我们将使用 Swift 5 构建配套的 iOS 界面,并使用 Python >= 3.10。由于此项目的代码是 100% 开源的,请随时提交拉取请求,或分叉项目以将其引向全新的方向。

您将需要什么

  • 在撰写本文时,您可以以 11-30 美元的价格购买到 COLMI R02 戒指。
  • 已安装 Xcode 16 的开发环境,以及可选的 Apple Developer Program 会员资格
  • 安装了 pandasnumpytorch 当然还有 transformersPython >= 3.10

致谢

本项目基于以下 Python 存储库 的代码和我的学习成果。

免责声明

首先作为一名医生,我有法律义务提醒您:您即将阅读的任何内容都不是医疗建议。现在,让我们开始让可穿戴设备发出蜂鸣声吧!

配对戒指

在我们深入研究代码之前,让我们首先了解一些蓝牙低功耗 (BLE) 的关键规范。BLE 采用简单的客户端-服务器模型,使用三个关键概念:中央设备服务特性。我们来分解一下这些概念

  • 中央设备(如您的 iPhone)启动并管理与外围设备(如我们的 COLMI R02 戒指)的连接。戒指广播其存在,等待手机连接。一次只能有一部手机连接到戒指。
  • 服务是戒指上相关功能的集合。把它们想象成类别——一个服务可能处理心率监测,另一个处理电池状态。每个服务都有一个唯一的标识符(UUID),客户端用它来查找。
  • 特性是每个服务中的实际数据点或控制机制。它们可以是只读的(如获取传感器数据)、只写的(如发送命令),或者两者兼有。一些特性还可以自动通知您的手机其值何时改变,这对于实时健康监测至关重要。

当您的手机连接到戒指时,它会找到所需的服务,然后与特定的特性进行交互以发送命令或接收数据。这种结构化方法确保了高效通信,同时保持了较长的电池续航时间。有了这些细节,让我们开始构建吧!

设置 Xcode 项目

让我们创建一个名为 Halo 且面向 iOS 的新项目。对于组织标识符,通常使用反向域名(例如 com.example)。我们将为此项目使用 com.FirstNameLastName

image/png

现在我们需要为我们的应用程序启用特定的功能。前往 Xcode 中的 Signing & Capabilities 选项卡,并启用以下 Background Modes

image/png

这些设置确保您的应用程序即使不在前台也能与戒指保持连接并处理数据。

下一步,我们将使用一个名为 AccessorySetupKit 的框架——Apple 用于将蓝牙和 Wi-Fi 配件连接到 iOS 应用程序的最新框架。它随 iOS 18 发布,取代了请求广泛蓝牙权限的传统方法,转而采用更集中的方法——您的应用程序只能访问用户明确批准的特定设备。

当用户想要将他们的 COLMI R02 戒指连接到我们的应用程序时,AccessorySetupKit 会显示一个系统界面,只显示附近兼容的设备。一旦选中,我们的应用程序就可以与戒指通信,而无需获得用户设备的完整蓝牙访问权限。这意味着为用户提供了更好的隐私,并为开发人员提供了更简单的连接管理。让我们逐步设置 AccessorySetupKit 以连接我们的戒指。

首先打开 Info.plist 文件,您可以在左侧边栏中找到它,或者导航到 Project Navigator (⌘1) > Your Target > Info。现在输入以下键值对以与我们的 COLMI R02 戒指配合使用:

  • 添加 NSAccessorySetupKitSupports 作为 Array 类型,并插入 Bluetooth 作为其第一个项。
  • 添加 NSAccessorySetupBluetoothServices 作为 Array 类型,并添加这些 UUID 作为 String 项:6E40FFF0-B5A3-F393-E0A9-E50E24DCCA9E0000180A-0000-1000-8000-00805F9B34FB

image/png

现在我们应该准备好了!🤗

如果您想跳过设置直接查看代码,可以在下方找到完整代码

戒指会话管理器

首先,我们将创建一个 RingSessionManager 类,它负责所有戒指通信。这个类将负责:

  • 扫描附近的戒指
  • 连接到戒指
  • 发现服务和特性
  • 向戒指读取和写入数据

步骤 1:创建 RingSessionManager

创建一个名为 RingSessionManager.swift 的新 Swift (⌘N) 文件。我已在下方突出显示了您需要实现的关键属性和方法。您将在本节末尾找到完整的类。让我们从定义类及其属性开始:

点击展开
@Observable
class RingSessionManager: NSObject {
    // Track connection state
    var peripheralConnected = false
    var pickerDismissed = true
    
    // Store our connected ring
    var currentRing: ASAccessory?
    private var session = ASAccessorySession()
    
    // Core Bluetooth objects
    private var manager: CBCentralManager?
    private var peripheral: CBPeripheral?
}

步骤 2:发现戒指

戒指使用特定的蓝牙服务广播自身。我们需要告诉 iOS 寻找什么。我们将使用戒指的蓝牙服务 UUID 创建一个 ASDiscoveryDescriptor 对象。此描述符将帮助 AccessorySetupKit 在扫描附近设备时识别戒指。

点击展开
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")! 替换为您戒指的图片。请确保将图片添加到您项目的资源目录中。我使用了以下产品图片。

步骤 3:显示戒指选择器

当用户想要连接他们的戒指时,我们显示 Apple 内置的设备选择器。

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

步骤 4:处理戒指选择

当用户从列表中选择他们的戒指时,我们需要处理连接。

点击展开
private func handleSessionEvent(event: ASAccessoryEvent) {
    switch event.eventType {
    case .accessoryAdded:
        guard let ring = event.accessory else { return }
        saveRing(ring: ring)
        
    case .activated:
        // Handle reconnection to previously paired ring
        guard let ring = session.accessories.first else { return }
        saveRing(ring: ring)
        
    case .accessoryRemoved:
        currentRing = nil
        manager = nil
    }
}

步骤 5:建立连接

一旦我们选择了戒指,我们就会发起实际的蓝牙连接。

点击展开
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)
}

步骤 6:理解委托方法

我们的 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")
        }
    }
}

步骤 7:使其在我们的应用程序中工作

打开 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()
}

如果一切顺利,您现在应该能够构建并运行您的应用程序。当您点击 添加戒指 按钮时,您将看到一个包含您的 COLMI R02 戒指在内的附近设备弹出窗口。选择它,应用程序将连接到它。🎉

image/png

在下一篇文章中,我们将探索如何向戒指读写数据,从电池电量开始,然后逐步深入到原始传感器数据(光电容积描记器、加速度计等)。然后,我们将使用这些数据来构建实时心率监测、活动追踪、睡眠阶段检测等功能。敬请期待!

社区

感谢您发布此内容——我非常期待看到后续文章。
这是对更昂贵的专有选项的一个非常受欢迎的替代方案。

·

这远未完成,但由于目前没有后续,为了推动事情进展,这是我的仓库 https://github.com/YannisDC/ColmiSmartRing

@cyrilzakka 您打算近期发布更多内容吗(加速度计读数、心率监测、睡眠阶段等)?

文章作者

@vlordier 是的,很可能在下个月内——我正在完成 https://github.com/huggingface/chat-macOS 的一个重大 v1.0 更新!抱歉延迟了!

太棒了,谢谢 🙂 我一定会密切关注的!!

注册登录 以评论