@craft-native/ios 0.0.221 → 0.0.222
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/templates/CraftHeartRate.swift +115 -12
package/package.json
CHANGED
|
@@ -135,6 +135,13 @@ final class CraftHeartRate: NSObject, CBCentralManagerDelegate, CBPeripheralDele
|
|
|
135
135
|
/// Fourteen hours of one reading a second: more is not one workout.
|
|
136
136
|
static let maxRecordedSamples = 50_000
|
|
137
137
|
|
|
138
|
+
private let clock: () -> Date
|
|
139
|
+
|
|
140
|
+
init(now: @escaping () -> Date = Date.init) {
|
|
141
|
+
self.clock = now
|
|
142
|
+
super.init()
|
|
143
|
+
}
|
|
144
|
+
|
|
138
145
|
/// The web view's coordinator, which sends an event to the page and every
|
|
139
146
|
/// native screen. False (or unset, in an app without a web view) and the
|
|
140
147
|
/// event goes to the native screens alone.
|
|
@@ -155,6 +162,12 @@ final class CraftHeartRate: NSObject, CBCentralManagerDelegate, CBPeripheralDele
|
|
|
155
162
|
private var connectDeadline: DispatchWorkItem?
|
|
156
163
|
private var latest: CraftHeartRateMeasurement?
|
|
157
164
|
private var latestAt: Date?
|
|
165
|
+
// Packet freshness is separate from a valid BPM: a monitor reporting no
|
|
166
|
+
// skin contact is still delivering notifications and needs no reconnect.
|
|
167
|
+
private var packetAt: Date?
|
|
168
|
+
private var subscriptionAt: Date?
|
|
169
|
+
private var recoveryTimer: Timer?
|
|
170
|
+
private var hasSubscribed = false
|
|
158
171
|
|
|
159
172
|
private var recordingId: String?
|
|
160
173
|
private var recordingStartedAt: Double?
|
|
@@ -189,7 +202,9 @@ final class CraftHeartRate: NSObject, CBCentralManagerDelegate, CBPeripheralDele
|
|
|
189
202
|
DispatchQueue.main.async {
|
|
190
203
|
self.restoreRecording()
|
|
191
204
|
switch method {
|
|
192
|
-
case "getState":
|
|
205
|
+
case "getState":
|
|
206
|
+
self.recoverQuietBroadcast()
|
|
207
|
+
completion(.success(self.state()))
|
|
193
208
|
case "requestAuthorization": self.requestAuthorization(completion: completion)
|
|
194
209
|
case "scan": self.scan(seconds: (options["timeout"] as? NSNumber)?.doubleValue, completion: completion)
|
|
195
210
|
case "stopScan":
|
|
@@ -255,7 +270,7 @@ final class CraftHeartRate: NSObject, CBCentralManagerDelegate, CBPeripheralDele
|
|
|
255
270
|
}
|
|
256
271
|
|
|
257
272
|
func state() -> [String: Any] {
|
|
258
|
-
let fresh = latestAt.map {
|
|
273
|
+
let fresh = latestAt.map { clock().timeIntervalSince($0) <= Self.staleAfter } ?? false
|
|
259
274
|
let device: Any = deviceId.map { ["id": $0.uuidString, "name": deviceName ?? "Heart rate monitor"] as [String: Any] } ?? NSNull()
|
|
260
275
|
return [
|
|
261
276
|
"available": bluetooth != "unsupported",
|
|
@@ -393,8 +408,10 @@ final class CraftHeartRate: NSObject, CBCentralManagerDelegate, CBPeripheralDele
|
|
|
393
408
|
}
|
|
394
409
|
whenReady {
|
|
395
410
|
if let reason = self.unusable() { return completion(.failure(reason)) }
|
|
396
|
-
//
|
|
411
|
+
// A connected link can lose its notification subscription when a
|
|
412
|
+
// broadcasting watch restarts. Keep healthy links, repair quiet ones.
|
|
397
413
|
if self.deviceId == uuid && self.wantsConnection {
|
|
414
|
+
self.recoverQuietBroadcast()
|
|
398
415
|
if self.connection == "connected" || !wait { return completion(.success(self.state())) }
|
|
399
416
|
self.connectWaiters.append(completion)
|
|
400
417
|
self.armConnectDeadline()
|
|
@@ -416,6 +433,8 @@ final class CraftHeartRate: NSObject, CBCentralManagerDelegate, CBPeripheralDele
|
|
|
416
433
|
self.deviceName = name
|
|
417
434
|
self.wantsConnection = true
|
|
418
435
|
self.connection = "connecting"
|
|
436
|
+
self.subscriptionAt = self.clock()
|
|
437
|
+
self.startRecoveryWatch()
|
|
419
438
|
self.remember(id: uuid, name: name)
|
|
420
439
|
if wait {
|
|
421
440
|
self.connectWaiters.append(completion)
|
|
@@ -454,6 +473,11 @@ final class CraftHeartRate: NSObject, CBCentralManagerDelegate, CBPeripheralDele
|
|
|
454
473
|
/// Lets go of the current strap without telling anyone.
|
|
455
474
|
private func release() {
|
|
456
475
|
wantsConnection = false
|
|
476
|
+
hasSubscribed = false
|
|
477
|
+
recoveryTimer?.invalidate()
|
|
478
|
+
recoveryTimer = nil
|
|
479
|
+
packetAt = nil
|
|
480
|
+
subscriptionAt = nil
|
|
457
481
|
simulatedTimer?.invalidate()
|
|
458
482
|
simulatedTimer = nil
|
|
459
483
|
if let peripheral, let central, peripheral.state != .disconnected { central.cancelPeripheralConnection(peripheral) }
|
|
@@ -474,7 +498,9 @@ final class CraftHeartRate: NSObject, CBCentralManagerDelegate, CBPeripheralDele
|
|
|
474
498
|
}
|
|
475
499
|
|
|
476
500
|
func centralManager(_ central: CBCentralManager, didConnect peripheral: CBPeripheral) {
|
|
477
|
-
guard peripheral.identifier == deviceId else { return }
|
|
501
|
+
guard peripheral.identifier == deviceId, wantsConnection else { return }
|
|
502
|
+
packetAt = nil
|
|
503
|
+
subscriptionAt = clock()
|
|
478
504
|
peripheral.delegate = self
|
|
479
505
|
peripheral.discoverServices([Self.serviceUUID])
|
|
480
506
|
}
|
|
@@ -491,6 +517,8 @@ final class CraftHeartRate: NSObject, CBCentralManagerDelegate, CBPeripheralDele
|
|
|
491
517
|
guard peripheral.identifier == deviceId else { return }
|
|
492
518
|
latest = nil
|
|
493
519
|
latestAt = nil
|
|
520
|
+
packetAt = nil
|
|
521
|
+
subscriptionAt = clock()
|
|
494
522
|
if wantsConnection {
|
|
495
523
|
connection = "reconnecting"
|
|
496
524
|
central.connect(peripheral)
|
|
@@ -501,7 +529,10 @@ final class CraftHeartRate: NSObject, CBCentralManagerDelegate, CBPeripheralDele
|
|
|
501
529
|
}
|
|
502
530
|
|
|
503
531
|
func peripheral(_ peripheral: CBPeripheral, didDiscoverServices error: Error?) {
|
|
532
|
+
guard peripheral.identifier == deviceId, wantsConnection else { return }
|
|
533
|
+
if error != nil { return restartBroadcast() }
|
|
504
534
|
guard let service = peripheral.services?.first(where: { $0.uuid == Self.serviceUUID }) else {
|
|
535
|
+
if hasSubscribed { return restartBroadcast() }
|
|
505
536
|
notAHeartRateMonitor(peripheral)
|
|
506
537
|
return
|
|
507
538
|
}
|
|
@@ -509,7 +540,10 @@ final class CraftHeartRate: NSObject, CBCentralManagerDelegate, CBPeripheralDele
|
|
|
509
540
|
}
|
|
510
541
|
|
|
511
542
|
func peripheral(_ peripheral: CBPeripheral, didDiscoverCharacteristicsFor service: CBService, error: Error?) {
|
|
543
|
+
guard peripheral.identifier == deviceId, wantsConnection, service.uuid == Self.serviceUUID else { return }
|
|
544
|
+
if error != nil { return restartBroadcast() }
|
|
512
545
|
guard let measurement = service.characteristics?.first(where: { $0.uuid == Self.measurementUUID }) else {
|
|
546
|
+
if hasSubscribed { return restartBroadcast() }
|
|
513
547
|
notAHeartRateMonitor(peripheral)
|
|
514
548
|
return
|
|
515
549
|
}
|
|
@@ -517,36 +551,104 @@ final class CraftHeartRate: NSObject, CBCentralManagerDelegate, CBPeripheralDele
|
|
|
517
551
|
}
|
|
518
552
|
|
|
519
553
|
func peripheral(_ peripheral: CBPeripheral, didUpdateNotificationStateFor characteristic: CBCharacteristic, error: Error?) {
|
|
520
|
-
guard characteristic.uuid == Self.measurementUUID, peripheral.identifier == deviceId else { return }
|
|
521
|
-
if characteristic.isNotifying {
|
|
554
|
+
guard characteristic.uuid == Self.measurementUUID, peripheral.identifier == deviceId, wantsConnection else { return }
|
|
555
|
+
if characteristic.isNotifying && error == nil {
|
|
556
|
+
hasSubscribed = true
|
|
557
|
+
subscriptionAt = clock()
|
|
522
558
|
connection = "connected"
|
|
523
559
|
emitState()
|
|
524
560
|
settleConnect(.success(state()))
|
|
525
|
-
} else
|
|
526
|
-
|
|
561
|
+
} else {
|
|
562
|
+
restartBroadcast()
|
|
527
563
|
}
|
|
528
564
|
}
|
|
529
565
|
|
|
566
|
+
// Garmin may invalidate its service when HR broadcast is toggled without
|
|
567
|
+
// dropping the physical link. Discover and subscribe to the replacement.
|
|
568
|
+
func peripheral(_ peripheral: CBPeripheral, didModifyServices invalidatedServices: [CBService]) {
|
|
569
|
+
guard peripheral.identifier == deviceId, wantsConnection,
|
|
570
|
+
invalidatedServices.contains(where: { $0.uuid == Self.serviceUUID }) else { return }
|
|
571
|
+
connection = "reconnecting"
|
|
572
|
+
latest = nil
|
|
573
|
+
latestAt = nil
|
|
574
|
+
packetAt = nil
|
|
575
|
+
subscriptionAt = clock()
|
|
576
|
+
peripheral.discoverServices([Self.serviceUUID])
|
|
577
|
+
emitState()
|
|
578
|
+
}
|
|
579
|
+
|
|
530
580
|
func peripheral(_ peripheral: CBPeripheral, didUpdateValueFor characteristic: CBCharacteristic, error: Error?) {
|
|
531
|
-
guard characteristic.uuid == Self.measurementUUID, peripheral.identifier == deviceId,
|
|
581
|
+
guard characteristic.uuid == Self.measurementUUID, peripheral.identifier == deviceId, wantsConnection else { return }
|
|
582
|
+
if error != nil { return restartBroadcast() }
|
|
583
|
+
guard let value = characteristic.value else { return }
|
|
532
584
|
received([UInt8](value))
|
|
533
585
|
}
|
|
534
586
|
|
|
535
587
|
private func notAHeartRateMonitor(_ peripheral: CBPeripheral) {
|
|
536
588
|
wantsConnection = false
|
|
589
|
+
hasSubscribed = false
|
|
590
|
+
recoveryTimer?.invalidate()
|
|
591
|
+
recoveryTimer = nil
|
|
537
592
|
central?.cancelPeripheralConnection(peripheral)
|
|
538
593
|
connection = "disconnected"
|
|
539
594
|
settleConnect(.failure(CraftHeartRateError(message: "That device does not send a heart rate", code: "NOT_HEART_RATE_MONITOR")))
|
|
540
595
|
emitState()
|
|
541
596
|
}
|
|
542
597
|
|
|
598
|
+
/// Run independently of any player or big-timer screen. A pending radio
|
|
599
|
+
/// connection stays pending until the watch returns; only a connected link
|
|
600
|
+
/// with a silent subscription is restarted.
|
|
601
|
+
private func startRecoveryWatch() {
|
|
602
|
+
recoveryTimer?.invalidate()
|
|
603
|
+
recoveryTimer = Timer.scheduledTimer(withTimeInterval: 5, repeats: true) { [weak self] _ in
|
|
604
|
+
self?.recoverQuietBroadcast()
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
private func recoverQuietBroadcast() {
|
|
609
|
+
guard wantsConnection, let since = packetAt ?? subscriptionAt else { return }
|
|
610
|
+
let quiet = clock().timeIntervalSince(since)
|
|
611
|
+
if connection == "connected" && quiet > 20 {
|
|
612
|
+
restartBroadcast()
|
|
613
|
+
} else if connection != "connected", peripheral?.state == .connected, quiet > 30 {
|
|
614
|
+
restartBroadcast()
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
/// Keep the recording and device choice while replacing the subscription.
|
|
619
|
+
/// CoreBluetooth's disconnect callback starts the next connection once the
|
|
620
|
+
/// previous one has actually closed.
|
|
621
|
+
private func restartBroadcast() {
|
|
622
|
+
guard wantsConnection else { return }
|
|
623
|
+
connection = "reconnecting"
|
|
624
|
+
latest = nil
|
|
625
|
+
latestAt = nil
|
|
626
|
+
packetAt = nil
|
|
627
|
+
subscriptionAt = clock()
|
|
628
|
+
emitState()
|
|
629
|
+
if simulated {
|
|
630
|
+
simulatedTimer?.invalidate()
|
|
631
|
+
simulatedTimer = nil
|
|
632
|
+
DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { [weak self] in self?.simulatedConnected() }
|
|
633
|
+
} else if let peripheral, let central, central.state == .poweredOn {
|
|
634
|
+
if peripheral.state == .disconnected { central.connect(peripheral) }
|
|
635
|
+
else if peripheral.state != .disconnecting { central.cancelPeripheralConnection(peripheral) }
|
|
636
|
+
}
|
|
637
|
+
}
|
|
638
|
+
|
|
543
639
|
// MARK: Readings
|
|
544
640
|
|
|
545
641
|
private func received(_ bytes: [UInt8]) {
|
|
546
642
|
// A strap reads 0 between losing the skin and giving up; that is no
|
|
547
643
|
// heart rate, and neither is anything a heart could not do.
|
|
548
|
-
|
|
549
|
-
let
|
|
644
|
+
packetAt = clock()
|
|
645
|
+
guard let reading = CraftHeartRateMeasurement.parse(bytes), reading.sensorContact != false, reading.bpm >= 20, reading.bpm <= 250 else {
|
|
646
|
+
latest = nil
|
|
647
|
+
latestAt = nil
|
|
648
|
+
emitState()
|
|
649
|
+
return
|
|
650
|
+
}
|
|
651
|
+
let now = clock()
|
|
550
652
|
latest = reading
|
|
551
653
|
latestAt = now
|
|
552
654
|
let timestamp = (now.timeIntervalSince1970 * 1000).rounded()
|
|
@@ -567,6 +669,7 @@ final class CraftHeartRate: NSObject, CBCentralManagerDelegate, CBPeripheralDele
|
|
|
567
669
|
private func simulatedConnected() {
|
|
568
670
|
guard simulated, wantsConnection, deviceId == Self.simulatedId else { return }
|
|
569
671
|
connection = "connected"
|
|
672
|
+
subscriptionAt = clock()
|
|
570
673
|
emitState()
|
|
571
674
|
settleConnect(.success(state()))
|
|
572
675
|
simulatedTimer?.invalidate()
|
|
@@ -671,7 +774,7 @@ final class CraftHeartRate: NSObject, CBCentralManagerDelegate, CBPeripheralDele
|
|
|
671
774
|
return completion(.failure(CraftHeartRateError(message: "Unable to create the heart-rate recording", code: "RECORDING_STORAGE_ERROR")))
|
|
672
775
|
}
|
|
673
776
|
recordingId = UUID().uuidString
|
|
674
|
-
recordingStartedAt = (
|
|
777
|
+
recordingStartedAt = (clock().timeIntervalSince1970 * 1000).rounded()
|
|
675
778
|
recordingPaused = false
|
|
676
779
|
recordedCount = 0
|
|
677
780
|
persistRecording()
|