Skip to content

Commit

Permalink
Implement onChange for @characteristic
Browse files Browse the repository at this point in the history
  • Loading branch information
Supereg committed Jan 23, 2024
1 parent f6627c0 commit 1681c43
Show file tree
Hide file tree
Showing 13 changed files with 212 additions and 62 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import SpeziBluetooth
public enum TemperatureType: UInt8 {
case reserved
case armpit
case body // TODO: general
case body
case ear
case finger
case gastrointestinalTract
Expand Down
3 changes: 3 additions & 0 deletions Sources/BluetoothServices/DeviceInformationService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,9 @@ import SpeziBluetooth
/// It is possible that none are implemented at all.
/// For more information refer to the specification.
public class DeviceInformationService: BluetoothService {
@Characteristic(id: "Hello World")
public var testString: String?

/// The manufacturer name string.
@Characteristic(id: .manufacturerNameStringCharacteristic)
public var manufacturerName: String?
Expand Down
7 changes: 6 additions & 1 deletion Sources/BluetoothServices/HealthThermometerService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -41,5 +41,10 @@ public class HealthThermometerService: BluetoothService {
public var measurementInterval: MeasurementInterval?


public init() {}
public init() {
$temperatureMeasurement.onChange { measurement in
self
// TODO: asdf
}
}
}
12 changes: 8 additions & 4 deletions Sources/SpeziBluetooth/Bluetooth.swift
Original file line number Diff line number Diff line change
Expand Up @@ -263,7 +263,8 @@ public class Bluetooth: Module, EnvironmentAccessible, BluetoothScanner {
// remove all delete keys
for key in nearbyDevices.keys where discoveredDevices[key] == nil {
checkForConnected = true
nearbyDevices.removeValue(forKey: key)
let device = nearbyDevices.removeValue(forKey: key)
device?.clearState()

Check warning on line 267 in Sources/SpeziBluetooth/Bluetooth.swift

View check run for this annotation

Codecov / codecov/patch

Sources/SpeziBluetooth/Bluetooth.swift#L266-L267

Added lines #L266 - L267 were not covered by tests
}

// add devices for new keys
Expand All @@ -273,9 +274,12 @@ public class Bluetooth: Module, EnvironmentAccessible, BluetoothScanner {
continue
}

let device = configuration.anyDeviceType.init()
device.inject(peripheral: peripheral)
nearbyDevices[uuid] = device

NotificationRegistrar.$instance.withValue(NotificationRegistrar()) {
let device = configuration.anyDeviceType.init()
device.inject(peripheral: peripheral)
nearbyDevices[uuid] = device
}

Check warning on line 282 in Sources/SpeziBluetooth/Bluetooth.swift

View check run for this annotation

Codecov / codecov/patch

Sources/SpeziBluetooth/Bluetooth.swift#L277-L282

Added lines #L277 - L282 were not covered by tests

checkForConnected = true
observePeripheralState(of: uuid)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,12 +31,12 @@ import CoreBluetooth
/// - ``isNotifying``
/// - ``enableNotifications(_:)``
public struct CharacteristicAccessors<Value> {
let id: CBUUID
fileprivate let context: CharacteristicContext<Value>
private let information: Characteristic<Value>.Information
private let context: CharacteristicPeripheralAssociation<Value>?


init(id: CBUUID, context: CharacteristicContext<Value>) {
self.id = id
init(information: Characteristic<Value>.Information, context: CharacteristicPeripheralAssociation<Value>?) {
self.information = information

Check warning on line 39 in Sources/SpeziBluetooth/Model/Characteristic/CharacteristicAccessor.swift

View check run for this annotation

Codecov / codecov/patch

Sources/SpeziBluetooth/Model/Characteristic/CharacteristicAccessor.swift#L38-L39

Added lines #L38 - L39 were not covered by tests
self.context = context
}
}
Expand All @@ -46,39 +46,58 @@ extension CharacteristicAccessors {
/// Determine if the characteristic is available.
///
/// Returns true if the characteristic is available for the current device.
/// It is ture if (a) the device is connected and (b) the device exposes the requested characteristic.
/// It is true if (a) the device is connected and (b) the device exposes the requested characteristic.
public var isPresent: Bool {
context.characteristic != nil
context?.characteristic != nil

Check warning on line 51 in Sources/SpeziBluetooth/Model/Characteristic/CharacteristicAccessor.swift

View check run for this annotation

Codecov / codecov/patch

Sources/SpeziBluetooth/Model/Characteristic/CharacteristicAccessor.swift#L51

Added line #L51 was not covered by tests
}

/// Properties of the characteristic.
///
/// Nil if device is not connected.
public var properties: CBCharacteristicProperties? {
context.characteristic?.properties
context?.characteristic?.properties

Check warning on line 58 in Sources/SpeziBluetooth/Model/Characteristic/CharacteristicAccessor.swift

View check run for this annotation

Codecov / codecov/patch

Sources/SpeziBluetooth/Model/Characteristic/CharacteristicAccessor.swift#L58

Added line #L58 was not covered by tests
}

/// Descriptors of the characteristic.
///
/// Nil if device is not connected or descriptors are not yet discovered.
public var descriptors: [CBDescriptor]? { // swiftlint:disable:this discouraged_optional_collection
context.characteristic?.descriptors
context?.characteristic?.descriptors

Check warning on line 65 in Sources/SpeziBluetooth/Model/Characteristic/CharacteristicAccessor.swift

View check run for this annotation

Codecov / codecov/patch

Sources/SpeziBluetooth/Model/Characteristic/CharacteristicAccessor.swift#L65

Added line #L65 was not covered by tests
}
}


extension CharacteristicAccessors where Value: ByteDecodable {
/// Characteristic is currently notifying about updated values.
///
/// This is false if device is not connected.
/// This is also false if device is not connected.
public var isNotifying: Bool {
context.characteristic?.isNotifying ?? false
context?.characteristic?.isNotifying ?? false
}

Check warning on line 76 in Sources/SpeziBluetooth/Model/Characteristic/CharacteristicAccessor.swift

View check run for this annotation

Codecov / codecov/patch

Sources/SpeziBluetooth/Model/Characteristic/CharacteristicAccessor.swift#L75-L76

Added lines #L75 - L76 were not covered by tests


public func onChange(_ perform: @escaping (Value) -> Void) { // TODO: docs
guard let context else {
// We save the instance in the global registrar if its available.
// It will be available if w e are instantiated through the Bluetooth module.
// This indirection is required to support self referencing closures without encountering a strong reference cycle.
NotificationRegistrar.instance?.insert(for: information, closure: perform)
return
}

context.setNotificationClosure(perform)

Check warning on line 88 in Sources/SpeziBluetooth/Model/Characteristic/CharacteristicAccessor.swift

View check run for this annotation

Codecov / codecov/patch

Sources/SpeziBluetooth/Model/Characteristic/CharacteristicAccessor.swift#L79-L88

Added lines #L79 - L88 were not covered by tests
}


/// Enable or disable characteristic notifications.
/// - Parameter enable: Flag indicating if notifications should be enabled.
public func enableNotifications(_ enable: Bool = true) async {
guard let context else {
// this will value will be populated to the context once it is set up // TODO: rename all context!
information.defaultNotify = enable
return
}

Check warning on line 100 in Sources/SpeziBluetooth/Model/Characteristic/CharacteristicAccessor.swift

View check run for this annotation

Codecov / codecov/patch

Sources/SpeziBluetooth/Model/Characteristic/CharacteristicAccessor.swift#L95-L100

Added lines #L95 - L100 were not covered by tests
if enable {
await context.enableNotifications()
} else {
Expand All @@ -92,7 +111,8 @@ extension CharacteristicAccessors where Value: ByteDecodable {
/// It might also throw a ``BluetoothError/notPresent`` or ``BluetoothError/incompatibleDataFormat`` error.
@discardableResult
public func read() async throws -> Value {
guard let characteristic = context.characteristic else {
guard let context,
let characteristic = context.characteristic else {

Check warning on line 115 in Sources/SpeziBluetooth/Model/Characteristic/CharacteristicAccessor.swift

View check run for this annotation

Codecov / codecov/patch

Sources/SpeziBluetooth/Model/Characteristic/CharacteristicAccessor.swift#L114-L115

Added lines #L114 - L115 were not covered by tests
throw BluetoothError.notPresent
}

Expand All @@ -117,7 +137,8 @@ extension CharacteristicAccessors where Value: ByteEncodable {
/// - Throws: Throws an `CBError` or `CBATTError` if the write fails.
/// It might also throw a ``BluetoothError/notPresent`` error.
public func write(_ value: Value) async throws {
guard let characteristic = context.characteristic else {
guard let context,
let characteristic = context.characteristic else {

Check warning on line 141 in Sources/SpeziBluetooth/Model/Characteristic/CharacteristicAccessor.swift

View check run for this annotation

Codecov / codecov/patch

Sources/SpeziBluetooth/Model/Characteristic/CharacteristicAccessor.swift#L140-L141

Added lines #L140 - L141 were not covered by tests
throw BluetoothError.notPresent
}

Expand All @@ -135,7 +156,8 @@ extension CharacteristicAccessors where Value: ByteEncodable {
/// - Throws: Throws an `CBError` or `CBATTError` if the write fails.
/// It might also throw a ``BluetoothError/notPresent`` error.
public func writeWithoutResponse(_ value: Value) async throws {
guard let characteristic = context.characteristic else {
guard let context,
let characteristic = context.characteristic else {

Check warning on line 160 in Sources/SpeziBluetooth/Model/Characteristic/CharacteristicAccessor.swift

View check run for this annotation

Codecov / codecov/patch

Sources/SpeziBluetooth/Model/Characteristic/CharacteristicAccessor.swift#L159-L160

Added lines #L159 - L160 were not covered by tests
throw BluetoothError.notPresent
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,16 +25,20 @@ private protocol DecodableCharacteristic {


/// Captures and synchronizes access to the state of a ``Characteristic`` property wrapper.
actor CharacteristicContext<Value> {
actor CharacteristicPeripheralAssociation<Value> {
let peripheral: BluetoothPeripheral
let characteristicId: CBUUID
let serviceId: CBUUID

private let characteristicBox: OptionalBox<CBCharacteristic>
private let valueBox: OptionalBox<Value>

/// This flag controls if we are supposed to be subscribed to characteristic notifications.
private var notify = false
/// The registration object we received from the ``BluetoothPeripheral`` for our notification handler.
private var registration: CharacteristicNotification?
/// The user supplied notification closure we use to forward notifications.
private let notificationClosure: OptionalBox<(Value) -> Void>

nonisolated var characteristic: CBCharacteristic? { // nil if device is not connected yet
characteristicBox.value
Expand All @@ -49,16 +53,18 @@ actor CharacteristicContext<Value> {
peripheral: BluetoothPeripheral,
serviceId: CBUUID,
characteristicId: CBUUID,
characteristic: CBCharacteristic?
characteristic: CBCharacteristic?,
notificationClosure: ((Value) -> Void)?
) {
self.peripheral = peripheral
self.serviceId = serviceId
self.characteristicId = characteristicId
self.characteristicBox = OptionalBox(value: characteristic)
self.valueBox = OptionalBox(value: nil)
self.notificationClosure = OptionalBox(value: notificationClosure)

Check warning on line 64 in Sources/SpeziBluetooth/Model/Characteristic/CharacteristicPeripheralAssociation.swift

View check run for this annotation

Codecov / codecov/patch

Sources/SpeziBluetooth/Model/Characteristic/CharacteristicPeripheralAssociation.swift#L64

Added line #L64 was not covered by tests
}

/// Setup the context. Must be called after initialization to set up all handlers and write the initial value.
/// Setup the association. Must be called after initialization to set up all handlers and write the initial value.
/// - Parameter defaultNotify: Flag indicating if notification handlers should be registered immediately.
func setup(defaultNotify: Bool) async {
trackServicesUpdates() // enable observation tracking for peripheral.services
Expand All @@ -76,6 +82,14 @@ actor CharacteristicContext<Value> {
}
}

nonisolated func clearState() { // signal from the Bluetooth state to cleanup the device
self.notificationClosure.value = nil // might contain a self reference!
}

Check warning on line 87 in Sources/SpeziBluetooth/Model/Characteristic/CharacteristicPeripheralAssociation.swift

View check run for this annotation

Codecov / codecov/patch

Sources/SpeziBluetooth/Model/Characteristic/CharacteristicPeripheralAssociation.swift#L85-L87

Added lines #L85 - L87 were not covered by tests

nonisolated func setNotificationClosure(_ closure: ((Value) -> Void)?) {
self.notificationClosure.value = closure
}

Check warning on line 91 in Sources/SpeziBluetooth/Model/Characteristic/CharacteristicPeripheralAssociation.swift

View check run for this annotation

Codecov / codecov/patch

Sources/SpeziBluetooth/Model/Characteristic/CharacteristicPeripheralAssociation.swift#L89-L91

Added lines #L89 - L91 were not covered by tests

/// Enable notifications (if not already) for the characteristic.
func enableNotifications() async {
guard !notify else {
Expand Down Expand Up @@ -147,16 +161,19 @@ actor CharacteristicContext<Value> {
}


extension CharacteristicContext: DecodableCharacteristic where Value: ByteDecodable {
extension CharacteristicPeripheralAssociation: DecodableCharacteristic where Value: ByteDecodable {
nonisolated func handleUpdateValueAssumingIsolation(_ data: Data?) {
// assumes this is called with actor isolation!
assertIsolated("\(#function) was called without actor isolation.")

Check warning on line 166 in Sources/SpeziBluetooth/Model/Characteristic/CharacteristicPeripheralAssociation.swift

View check run for this annotation

Codecov / codecov/patch

Sources/SpeziBluetooth/Model/Characteristic/CharacteristicPeripheralAssociation.swift#L166

Added line #L166 was not covered by tests
if let data {
guard let value = Value(data: data) else {
Bluetooth.logger.error("Could decode updated value for characteristic \(self.characteristic?.debugIdentifier ?? self.characteristicId.uuidString). Invalid format!")
return
}

self.valueBox.value = value
if let handler = notificationClosure.value {
handler(value)
}

Check warning on line 176 in Sources/SpeziBluetooth/Model/Characteristic/CharacteristicPeripheralAssociation.swift

View check run for this annotation

Codecov / codecov/patch

Sources/SpeziBluetooth/Model/Characteristic/CharacteristicPeripheralAssociation.swift#L174-L176

Added lines #L174 - L176 were not covered by tests
} else {
self.valueBox.value = nil
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
//
// This source file is part of the Stanford Spezi open-source project
//
// SPDX-FileCopyrightText: 2024 Stanford University and the project authors (see CONTRIBUTORS.md)
//
// SPDX-License-Identifier: MIT
//

import Foundation


/// Tracking notification closure registrations for ``Characteristic`` when peripheral is not available yet.
final class NotificationRegistrar {
struct Entry<Value> {
let closure: (Value) -> Void
}

// task local value ensures nobody is interfering here and resolves thread safety
@TaskLocal static var instance: NotificationRegistrar?


private var registrations: [ObjectIdentifier: Any] = [:] // TODO: figure out the type!

Check warning on line 22 in Sources/SpeziBluetooth/Model/Characteristic/NotificationRegistrar.swift

View check run for this annotation

Codecov / codecov/patch

Sources/SpeziBluetooth/Model/Characteristic/NotificationRegistrar.swift#L22

Added line #L22 was not covered by tests

init() {}

Check warning on line 24 in Sources/SpeziBluetooth/Model/Characteristic/NotificationRegistrar.swift

View check run for this annotation

Codecov / codecov/patch

Sources/SpeziBluetooth/Model/Characteristic/NotificationRegistrar.swift#L24

Added line #L24 was not covered by tests

func insert<Value>(for information: Characteristic<Value>.Information, closure: @escaping (Value) -> Void) {
registrations[information.objectId] = Entry(closure: closure)
}

Check warning on line 28 in Sources/SpeziBluetooth/Model/Characteristic/NotificationRegistrar.swift

View check run for this annotation

Codecov / codecov/patch

Sources/SpeziBluetooth/Model/Characteristic/NotificationRegistrar.swift#L26-L28

Added lines #L26 - L28 were not covered by tests

func retrieve<Value>(for information: Characteristic<Value>.Information) -> ((Value) -> Void)? {
guard let optionalEntry = registrations[information.objectId],
let entry = optionalEntry as? Entry<Value> else {
return nil
}
return entry.closure
}

Check warning on line 36 in Sources/SpeziBluetooth/Model/Characteristic/NotificationRegistrar.swift

View check run for this annotation

Codecov / codecov/patch

Sources/SpeziBluetooth/Model/Characteristic/NotificationRegistrar.swift#L30-L36

Added lines #L30 - L36 were not covered by tests
}


extension Characteristic.Information {
/// Memory address as an identifier for this Characteristic instance.
fileprivate var objectId: ObjectIdentifier {
ObjectIdentifier(self)
}

Check warning on line 44 in Sources/SpeziBluetooth/Model/Characteristic/NotificationRegistrar.swift

View check run for this annotation

Codecov / codecov/patch

Sources/SpeziBluetooth/Model/Characteristic/NotificationRegistrar.swift#L42-L44

Added lines #L42 - L44 were not covered by tests
}
Loading

0 comments on commit 1681c43

Please sign in to comment.