craft-native 0.0.151 → 0.0.152
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/dist/api/mobile.d.ts +113 -0
- package/dist/cli.js +1 -1
- package/dist/index.cjs +2 -1
- package/dist/index.js +2 -1
- package/dist/ios/src/index.d.ts +9 -0
- package/dist/ios/src/index.js +5 -1
- package/dist/ios/templates/CraftApp.swift +56 -1
- package/dist/ios/templates/CraftHeartRate.swift +699 -0
- package/dist/ios/templates/CraftNativeActions.swift +16 -0
- package/dist/mobile.js +163 -0
- package/package.json +1 -1
package/dist/api/mobile.d.ts
CHANGED
|
@@ -825,6 +825,118 @@ export declare const music: {
|
|
|
825
825
|
*/
|
|
826
826
|
onChange(callback: (nowPlaying: NowPlaying) => void): () => void;
|
|
827
827
|
};
|
|
828
|
+
export type HeartRateAuthorization = 'authorized' | 'denied' | 'restricted' | 'notDetermined' | 'unavailable';
|
|
829
|
+
export type BluetoothState = 'poweredOn' | 'poweredOff' | 'unauthorized' | 'unsupported' | 'resetting' | 'unknown';
|
|
830
|
+
export type HeartRateConnection = 'disconnected' | 'connecting' | 'connected' | 'reconnecting';
|
|
831
|
+
export interface HeartRateDevice {
|
|
832
|
+
id: string;
|
|
833
|
+
name: string;
|
|
834
|
+
/** Signal strength, dBm; 0 for a strap the phone is already connected to. */
|
|
835
|
+
rssi: number | null;
|
|
836
|
+
}
|
|
837
|
+
export interface HeartRateRecordingState {
|
|
838
|
+
id: string | null;
|
|
839
|
+
active: boolean;
|
|
840
|
+
paused: boolean;
|
|
841
|
+
/** ms since the epoch. */
|
|
842
|
+
startedAt: number | null;
|
|
843
|
+
sampleCount: number | null;
|
|
844
|
+
}
|
|
845
|
+
export interface HeartRateState {
|
|
846
|
+
/** False off a phone, in a build without `enableHeartRate`, or on a phone without Bluetooth. */
|
|
847
|
+
available: boolean;
|
|
848
|
+
bluetooth: BluetoothState;
|
|
849
|
+
authorization: HeartRateAuthorization;
|
|
850
|
+
scanning: boolean;
|
|
851
|
+
connection: HeartRateConnection;
|
|
852
|
+
/** The strap connected, or being connected to. */
|
|
853
|
+
device: HeartRateDevice | null;
|
|
854
|
+
/** The strap `reconnect()` asks for. */
|
|
855
|
+
lastDevice: HeartRateDevice | null;
|
|
856
|
+
/** The latest reading while it is fresh (ten seconds), else null. */
|
|
857
|
+
bpm: number | null;
|
|
858
|
+
lastReadingAt: number | null;
|
|
859
|
+
recording: HeartRateRecordingState;
|
|
860
|
+
}
|
|
861
|
+
export interface HeartRateReading {
|
|
862
|
+
bpm: number;
|
|
863
|
+
/** Milliseconds between beats, oldest first; empty when the strap sends none. */
|
|
864
|
+
rrIntervals: number[];
|
|
865
|
+
/** Whether the strap touches skin; null when it cannot tell. */
|
|
866
|
+
contact: boolean | null;
|
|
867
|
+
energyExpended: number | null;
|
|
868
|
+
/** ms since the epoch. */
|
|
869
|
+
timestamp: number;
|
|
870
|
+
deviceId: string | null;
|
|
871
|
+
}
|
|
872
|
+
/** A reading as a recording keeps it. */
|
|
873
|
+
export interface HeartRateSample {
|
|
874
|
+
/** ms since the epoch. */
|
|
875
|
+
timestamp: number;
|
|
876
|
+
bpm: number;
|
|
877
|
+
/** Milliseconds between beats, when the strap sent them. */
|
|
878
|
+
rr?: number[];
|
|
879
|
+
}
|
|
880
|
+
export type HeartRateMethod = 'getState' | 'requestAuthorization' | 'scan' | 'stopScan' | 'getDevices' | 'connect' | 'reconnect' | 'disconnect' | 'startRecording' | 'pauseRecording' | 'resumeRecording' | 'stopRecording' | 'getRecordingState' | 'readRecording';
|
|
881
|
+
export declare function normalizeHeartRateDevice(value: unknown): HeartRateDevice | null;
|
|
882
|
+
/** A state from native, made safe to render whatever an older or newer build sent. */
|
|
883
|
+
export declare function normalizeHeartRateState(value: unknown): HeartRateState;
|
|
884
|
+
/** A reading event's detail, or null for one that is not a usable reading. */
|
|
885
|
+
export declare function normalizeHeartRateReading(value: unknown): HeartRateReading | null;
|
|
886
|
+
/** A recording's samples, oldest first, one per timestamp, implausible ones left out. */
|
|
887
|
+
export declare function normalizeHeartRateSamples(value: unknown): HeartRateSample[];
|
|
888
|
+
export declare const heartRate: {
|
|
889
|
+
/** Whether this app can read a heart-rate strap. */
|
|
890
|
+
isAvailable(): boolean;
|
|
891
|
+
/** Whether this app build answers `method`; an older build may lack one. */
|
|
892
|
+
supports(method: HeartRateMethod): boolean;
|
|
893
|
+
/** Bluetooth, the connection, the strap and its latest reading, without asking anything. */
|
|
894
|
+
getState: () => Promise<HeartRateState>;
|
|
895
|
+
/**
|
|
896
|
+
* Ask for Bluetooth: iOS's prompt the first time, its remembered answer
|
|
897
|
+
* after that. Scanning and connecting ask too; call this from the person's
|
|
898
|
+
* tap to ask first.
|
|
899
|
+
*/
|
|
900
|
+
requestAuthorization: () => Promise<HeartRateAuthorization>;
|
|
901
|
+
/** Look for straps for `timeout` seconds (15 by default); `onDevice` hears each one. */
|
|
902
|
+
scan: (options?: {
|
|
903
|
+
timeout?: number;
|
|
904
|
+
}) => Promise<HeartRateState>;
|
|
905
|
+
stopScan: () => Promise<HeartRateState>;
|
|
906
|
+
/** What the last scan found, strongest signal first. */
|
|
907
|
+
getDevices: () => Promise<HeartRateDevice[]>;
|
|
908
|
+
/**
|
|
909
|
+
* Connect to a strap from `scan` and subscribe to its readings; it is
|
|
910
|
+
* remembered for `reconnect()`. Rejects with code TIMEOUT when it does not
|
|
911
|
+
* answer within 15 s, while still asking for it: a strap wakes up when worn.
|
|
912
|
+
*/
|
|
913
|
+
connect: (id: string) => Promise<HeartRateState>;
|
|
914
|
+
/** Ask for the remembered strap again, without waiting for it. */
|
|
915
|
+
reconnect: () => Promise<HeartRateState>;
|
|
916
|
+
/** Stop asking for the strap; `forget` also drops it from memory. */
|
|
917
|
+
disconnect: (options?: {
|
|
918
|
+
forget?: boolean;
|
|
919
|
+
}) => Promise<HeartRateState>;
|
|
920
|
+
/** Keep every reading natively from now on: screen locked, page suspended or app closed. */
|
|
921
|
+
startRecording: () => Promise<HeartRateRecordingState>;
|
|
922
|
+
pauseRecording: () => Promise<HeartRateRecordingState>;
|
|
923
|
+
resumeRecording: () => Promise<HeartRateRecordingState>;
|
|
924
|
+
/** Ends the recording and hands back every reading it kept. */
|
|
925
|
+
stopRecording: () => Promise<HeartRateRecordingState & {
|
|
926
|
+
samples: HeartRateSample[];
|
|
927
|
+
}>;
|
|
928
|
+
getRecordingState: () => Promise<HeartRateRecordingState>;
|
|
929
|
+
/** The recording so far; only what came after `since` (ms) when given. */
|
|
930
|
+
readRecording: (options?: {
|
|
931
|
+
since?: number;
|
|
932
|
+
}) => Promise<HeartRateSample[]>;
|
|
933
|
+
/** Every reading from the connected strap, about once a second. */
|
|
934
|
+
onReading: (callback: (reading: HeartRateReading) => void) => (() => void);
|
|
935
|
+
/** Each strap a scan finds. */
|
|
936
|
+
onDevice: (callback: (device: HeartRateDevice) => void) => (() => void);
|
|
937
|
+
/** Bluetooth, scanning or the connection changed. */
|
|
938
|
+
onState: (callback: (state: HeartRateState) => void) => (() => void);
|
|
939
|
+
};
|
|
828
940
|
export declare const deepLinks: {
|
|
829
941
|
getInitialURL(): Promise<string | null>;
|
|
830
942
|
/**
|
|
@@ -1308,6 +1420,7 @@ declare const mobile: {
|
|
|
1308
1420
|
brightness: typeof brightness;
|
|
1309
1421
|
speech: typeof speech;
|
|
1310
1422
|
music: typeof music;
|
|
1423
|
+
heartRate: typeof heartRate;
|
|
1311
1424
|
deepLinks: typeof deepLinks;
|
|
1312
1425
|
network: typeof network;
|
|
1313
1426
|
appReview: typeof appReview;
|
package/dist/cli.js
CHANGED
|
@@ -3303,7 +3303,7 @@ function pinCraftNativeDependency(packagePath, craftVersion) {
|
|
|
3303
3303
|
`);
|
|
3304
3304
|
}
|
|
3305
3305
|
// package.json
|
|
3306
|
-
var version = "0.0.
|
|
3306
|
+
var version = "0.0.152";
|
|
3307
3307
|
|
|
3308
3308
|
// bin/cli.ts
|
|
3309
3309
|
var spawnedFrom = process6.env[CRAFT_CLI_SPAWN_MARKER];
|
package/dist/index.cjs
CHANGED
|
@@ -310,7 +310,7 @@ function craftBinaryNotFoundMessage(triedPath) {
|
|
|
310
310
|
`);
|
|
311
311
|
}
|
|
312
312
|
// package.json
|
|
313
|
-
var version = "0.0.
|
|
313
|
+
var version = "0.0.152";
|
|
314
314
|
|
|
315
315
|
// src/sdk-version.ts
|
|
316
316
|
var SDK_VERSION = version;
|
|
@@ -5325,6 +5325,7 @@ var notifications = {
|
|
|
5325
5325
|
};
|
|
5326
5326
|
var speakingOnTheWeb = new Set;
|
|
5327
5327
|
var musicWatch = { listeners: new Set, stop: null };
|
|
5328
|
+
var heartRateWatch = { readings: new Set, devices: new Set, states: new Set, stop: null };
|
|
5328
5329
|
function onCraftEvent(eventName, callback) {
|
|
5329
5330
|
if (typeof globalThis === "undefined")
|
|
5330
5331
|
return () => {};
|
package/dist/index.js
CHANGED
|
@@ -58,7 +58,7 @@ function craftBinaryNotFoundMessage(triedPath) {
|
|
|
58
58
|
`);
|
|
59
59
|
}
|
|
60
60
|
// package.json
|
|
61
|
-
var version = "0.0.
|
|
61
|
+
var version = "0.0.152";
|
|
62
62
|
|
|
63
63
|
// src/sdk-version.ts
|
|
64
64
|
var SDK_VERSION = version;
|
|
@@ -5085,6 +5085,7 @@ var notifications = {
|
|
|
5085
5085
|
};
|
|
5086
5086
|
var speakingOnTheWeb = new Set;
|
|
5087
5087
|
var musicWatch = { listeners: new Set, stop: null };
|
|
5088
|
+
var heartRateWatch = { readings: new Set, devices: new Set, states: new Set, stop: null };
|
|
5088
5089
|
function onCraftEvent(eventName, callback) {
|
|
5089
5090
|
if (typeof globalThis === "undefined")
|
|
5090
5091
|
return () => {};
|
package/dist/ios/src/index.d.ts
CHANGED
|
@@ -68,6 +68,15 @@ export interface CraftConfig {
|
|
|
68
68
|
* `craft.music.requestAuthorization()`, never at launch.
|
|
69
69
|
*/
|
|
70
70
|
enableAppleMusic?: boolean;
|
|
71
|
+
/**
|
|
72
|
+
* Read a Bluetooth heart-rate strap, or a watch broadcasting its heart rate
|
|
73
|
+
* (the standard Heart Rate Service), through `craft.heartRate`: scan,
|
|
74
|
+
* connect, live readings with RR intervals, and a recording kept natively.
|
|
75
|
+
* Adds the `bluetooth-central` background mode, so readings keep coming
|
|
76
|
+
* during a workout with the screen locked. iOS asks for Bluetooth the first
|
|
77
|
+
* time the page scans or connects.
|
|
78
|
+
*/
|
|
79
|
+
enableHeartRate?: boolean;
|
|
71
80
|
/**
|
|
72
81
|
* Purpose strings in the app's own words, by Info.plist key
|
|
73
82
|
* (`{ NSAppleMusicUsageDescription: 'Shows the song playing during a workout.' }`).
|
package/dist/ios/src/index.js
CHANGED
|
@@ -170,6 +170,7 @@ var DEFAULT_CONFIG = {
|
|
|
170
170
|
enableAR: false,
|
|
171
171
|
enableMLKit: false,
|
|
172
172
|
enableAppleMusic: false,
|
|
173
|
+
enableHeartRate: false,
|
|
173
174
|
iosVersion: "16.0",
|
|
174
175
|
watchosVersion: "9.0",
|
|
175
176
|
teamId: "",
|
|
@@ -226,7 +227,7 @@ function renderUsageDescriptions(config) {
|
|
|
226
227
|
[config.enableBackgroundLocation, "NSLocationAlwaysAndWhenInUseUsageDescription", `${config.appName} continues recording your route when the screen is locked or the app is in the background.`],
|
|
227
228
|
[config.enableContacts, "NSContactsUsageDescription", `${config.appName} accesses contacts only when you choose a contact feature.`],
|
|
228
229
|
[config.enableCalendar, "NSCalendarsUsageDescription", `${config.appName} accesses your calendar only when you choose a calendar feature.`],
|
|
229
|
-
[config.enableBluetooth, "NSBluetoothAlwaysUsageDescription", `${config.appName} uses Bluetooth to connect to nearby devices.`],
|
|
230
|
+
[config.enableBluetooth || config.enableHeartRate, "NSBluetoothAlwaysUsageDescription", config.enableHeartRate ? `${config.appName} connects to your heart-rate monitor over Bluetooth to show and record your heart rate during a workout.` : `${config.appName} uses Bluetooth to connect to nearby devices.`],
|
|
230
231
|
[config.enableMotionSensors, "NSMotionUsageDescription", `${config.appName} uses motion data for activity features.`],
|
|
231
232
|
[config.enableNFC, "NFCReaderUsageDescription", `${config.appName} reads NFC tags when you start a scan.`],
|
|
232
233
|
[config.enableLocalNetwork, "NSLocalNetworkUsageDescription", `${config.appName} connects to your own devices on this network.`],
|
|
@@ -309,6 +310,8 @@ function renderBackgroundModes(config) {
|
|
|
309
310
|
modes.add("processing");
|
|
310
311
|
if (config.enablePushNotifications)
|
|
311
312
|
modes.add("remote-notification");
|
|
313
|
+
if (config.enableHeartRate)
|
|
314
|
+
modes.add("bluetooth-central");
|
|
312
315
|
if (!modes.size)
|
|
313
316
|
return "";
|
|
314
317
|
return ` <key>UIBackgroundModes</key>
|
|
@@ -787,6 +790,7 @@ async function init(options) {
|
|
|
787
790
|
cpSync(join(TEMPLATES_DIR, "CraftNativeActions.swift"), join(output, "Sources", "CraftNativeActions.swift"));
|
|
788
791
|
cpSync(join(TEMPLATES_DIR, "CraftHealth.swift"), join(output, "Sources", "CraftHealth.swift"));
|
|
789
792
|
cpSync(join(TEMPLATES_DIR, "CraftMusic.swift"), join(output, "Sources", "CraftMusic.swift"));
|
|
793
|
+
cpSync(join(TEMPLATES_DIR, "CraftHeartRate.swift"), join(output, "Sources", "CraftHeartRate.swift"));
|
|
790
794
|
cpSync(join(TEMPLATES_DIR, "CraftNativeMutation.swift"), join(output, "Sources", "CraftNativeMutation.swift"));
|
|
791
795
|
cpSync(join(TEMPLATES_DIR, "CraftNativeUI.swift"), join(output, "Sources", "CraftNativeUI.swift"));
|
|
792
796
|
cpSync(join(TEMPLATES_DIR, "CraftNativeServices.swift"), join(output, "Sources", "CraftNativeServices.swift"));
|
|
@@ -1119,6 +1119,9 @@ struct CraftConfig: Codable {
|
|
|
1119
1119
|
var enableMLKit: Bool = false
|
|
1120
1120
|
/// The Music app's song and transport through `craft.music` (CraftMusic.swift).
|
|
1121
1121
|
var enableAppleMusic: Bool = false
|
|
1122
|
+
/// A Bluetooth heart-rate strap (or a watch broadcasting its heart rate)
|
|
1123
|
+
/// through `craft.heartRate` (CraftHeartRate.swift).
|
|
1124
|
+
var enableHeartRate: Bool = false
|
|
1122
1125
|
var devServerURL: String? = nil
|
|
1123
1126
|
var trustedOrigins: [String] = []
|
|
1124
1127
|
/// The longest the launch splash stays up when the page neither says it is
|
|
@@ -1641,6 +1644,16 @@ struct CraftWebView: UIViewRepresentable {
|
|
|
1641
1644
|
if config.enableMotionSensors {
|
|
1642
1645
|
motionManager = CMMotionManager()
|
|
1643
1646
|
}
|
|
1647
|
+
if config.enableHeartRate {
|
|
1648
|
+
// The strap's readings and connection changes reach the page,
|
|
1649
|
+
// and through sendToWeb every native screen too. False once
|
|
1650
|
+
// this coordinator is gone: native screens still hear them.
|
|
1651
|
+
CraftHeartRate.shared.pageSink = { [weak self] detail in
|
|
1652
|
+
guard let self else { return false }
|
|
1653
|
+
self.sendToWeb("craftHeartRate", data: detail)
|
|
1654
|
+
return true
|
|
1655
|
+
}
|
|
1656
|
+
}
|
|
1644
1657
|
if config.enableWatchApp {
|
|
1645
1658
|
setupWatchConnectivity()
|
|
1646
1659
|
}
|
|
@@ -2213,6 +2226,22 @@ struct CraftWebView: UIViewRepresentable {
|
|
|
2213
2226
|
} else {
|
|
2214
2227
|
rejectCallback(callbackId, error: "Apple Music is disabled", code: "CAPABILITY_DISABLED")
|
|
2215
2228
|
}
|
|
2229
|
+
// MARK: - Heart rate (CraftHeartRate.swift)
|
|
2230
|
+
// Gated on enableHeartRate. One action for every method, as a
|
|
2231
|
+
// native screen's HeartRate module calls them; asking for
|
|
2232
|
+
// Bluetooth has its own, which waits on the person.
|
|
2233
|
+
case "heartRate", "heartRateRequestAuthorization":
|
|
2234
|
+
if !config.enableHeartRate {
|
|
2235
|
+
rejectCallback(callbackId, error: "Heart rate is disabled", code: "CAPABILITY_DISABLED")
|
|
2236
|
+
} else {
|
|
2237
|
+
let method = action == "heartRate" ? body["method"] as? String ?? "" : "requestAuthorization"
|
|
2238
|
+
CraftHeartRate.shared.perform(method: method, args: [body["options"] as? [String: Any] ?? [:]]) { [weak self] result in
|
|
2239
|
+
switch result {
|
|
2240
|
+
case .success(let value): self?.resolveCallback(callbackId, result: value)
|
|
2241
|
+
case .failure(let failure): self?.rejectCallback(callbackId, error: failure.message, code: failure.code)
|
|
2242
|
+
}
|
|
2243
|
+
}
|
|
2244
|
+
}
|
|
2216
2245
|
// MARK: - Orientation Lock
|
|
2217
2246
|
case "lockOrientation":
|
|
2218
2247
|
if config.enableOrientationLock {
|
|
@@ -3198,6 +3227,7 @@ struct CraftWebView: UIViewRepresentable {
|
|
|
3198
3227
|
flashlight: true,
|
|
3199
3228
|
brightness: true,
|
|
3200
3229
|
music: \(config.enableAppleMusic),
|
|
3230
|
+
heartRate: \(config.enableHeartRate),
|
|
3201
3231
|
speech: true,
|
|
3202
3232
|
network: true,
|
|
3203
3233
|
deviceInfo: true,
|
|
@@ -4737,6 +4767,7 @@ struct CraftWebView: UIViewRepresentable {
|
|
|
4737
4767
|
var personFacing = {
|
|
4738
4768
|
requestHealthAuthorization: true,
|
|
4739
4769
|
musicRequestAuthorization: true,
|
|
4770
|
+
heartRateRequestAuthorization: true,
|
|
4740
4771
|
requestPermission: true,
|
|
4741
4772
|
signInWithApple: true,
|
|
4742
4773
|
dialogAlert: true,
|
|
@@ -4951,6 +4982,30 @@ struct CraftWebView: UIViewRepresentable {
|
|
|
4951
4982
|
},
|
|
4952
4983
|
observe: function(enabled) { return craft._invoke('musicObserve', {enabled: enabled !== false}); }
|
|
4953
4984
|
};
|
|
4985
|
+
// A Bluetooth heart-rate strap, or a watch broadcasting its
|
|
4986
|
+
// heart rate. Readings, devices found by scan() and connection
|
|
4987
|
+
// changes arrive as craftHeartRate events ({type: 'reading' |
|
|
4988
|
+
// 'device' | 'state'}); startRecording() keeps every reading
|
|
4989
|
+
// natively, screen locked or app closed, until stopRecording().
|
|
4990
|
+
(function() {
|
|
4991
|
+
function heartRate(method, options) { return craft._invoke('heartRate', {method: method, options: options || {}}); }
|
|
4992
|
+
craft.heartRate = {
|
|
4993
|
+
getState: function() { return heartRate('getState'); },
|
|
4994
|
+
requestAuthorization: function() { return craft._invoke('heartRateRequestAuthorization'); },
|
|
4995
|
+
scan: function(options) { return heartRate('scan', {timeout: options && typeof options.timeout === 'number' ? options.timeout : null}); },
|
|
4996
|
+
stopScan: function() { return heartRate('stopScan'); },
|
|
4997
|
+
getDevices: function() { return heartRate('getDevices'); },
|
|
4998
|
+
connect: function(id) { return heartRate('connect', {id: typeof id === 'string' ? id : id && id.id}); },
|
|
4999
|
+
reconnect: function() { return heartRate('reconnect'); },
|
|
5000
|
+
disconnect: function(options) { return heartRate('disconnect', {forget: !!(options && options.forget)}); },
|
|
5001
|
+
startRecording: function() { return heartRate('startRecording'); },
|
|
5002
|
+
pauseRecording: function() { return heartRate('pauseRecording'); },
|
|
5003
|
+
resumeRecording: function() { return heartRate('resumeRecording'); },
|
|
5004
|
+
stopRecording: function() { return heartRate('stopRecording'); },
|
|
5005
|
+
getRecordingState: function() { return heartRate('getRecordingState'); },
|
|
5006
|
+
readRecording: function(options) { return heartRate('readRecording', {since: options && typeof options.since === 'number' ? options.since : null}); }
|
|
5007
|
+
};
|
|
5008
|
+
})();
|
|
4954
5009
|
craft.liveActivity = {
|
|
4955
5010
|
start: function(options) { return craft._invoke('startLiveActivity', options || {}); },
|
|
4956
5011
|
update: function(idOrState, state) {
|
|
@@ -8450,7 +8505,7 @@ final class CraftZigRuntime: NSObject {
|
|
|
8450
8505
|
enum CraftWebServices {
|
|
8451
8506
|
private static var waiting: [String: (Result<Any, CraftNativeActionError>) -> Void] = [:]
|
|
8452
8507
|
/// Coordinator events a native screen hears too.
|
|
8453
|
-
static let forwardedEvents: Set<String> = ["craftLocationUpdate", "craftLocationError", "craftMusicChange"]
|
|
8508
|
+
static let forwardedEvents: Set<String> = ["craftLocationUpdate", "craftLocationError", "craftMusicChange", "craftHeartRate"]
|
|
8454
8509
|
static let event = Notification.Name("craftWebServicesEvent")
|
|
8455
8510
|
|
|
8456
8511
|
static var isAvailable: Bool { CraftSwiftShim.coordinator != nil }
|
|
@@ -0,0 +1,699 @@
|
|
|
1
|
+
import CoreBluetooth
|
|
2
|
+
import Foundation
|
|
3
|
+
import UIKit
|
|
4
|
+
|
|
5
|
+
/// A heart-rate call that did not happen: the message a page sees, and the
|
|
6
|
+
/// bridge's error code.
|
|
7
|
+
struct CraftHeartRateError: Error {
|
|
8
|
+
let message: String
|
|
9
|
+
var code: String = "CRAFT_ERROR"
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/// One Heart Rate Measurement (characteristic 0x2A37 of the Bluetooth SIG's
|
|
13
|
+
/// Heart Rate Service, 0x180D), decoded.
|
|
14
|
+
///
|
|
15
|
+
/// The payload is a flags byte and then the fields the flags announce, all
|
|
16
|
+
/// little-endian:
|
|
17
|
+
///
|
|
18
|
+
/// bit 0 the heart rate is a uint16 (else a uint8)
|
|
19
|
+
/// bits 1-2 sensor contact: bit 2 says the strap reports it at all,
|
|
20
|
+
/// bit 1 that the skin is touching (a strap worn loose reads 0)
|
|
21
|
+
/// bit 3 a uint16 of energy expended, kilojoules, follows the rate
|
|
22
|
+
/// bit 4 uint16 RR intervals follow, as many as fit, in 1/1024 s
|
|
23
|
+
///
|
|
24
|
+
/// Every strap and every watch that broadcasts its heart rate (Polar, Garmin,
|
|
25
|
+
/// Wahoo, an Apple Watch with a broadcasting app) sends this one shape.
|
|
26
|
+
struct CraftHeartRateMeasurement: Equatable {
|
|
27
|
+
/// Beats per minute.
|
|
28
|
+
let bpm: Int
|
|
29
|
+
/// Whether the strap is touching skin; nil when it cannot tell.
|
|
30
|
+
let sensorContact: Bool?
|
|
31
|
+
/// Kilojoules since the strap was last reset; nil when it does not count them.
|
|
32
|
+
let energyExpended: Int?
|
|
33
|
+
/// The time between beats, in milliseconds, oldest first.
|
|
34
|
+
let rrIntervals: [Double]
|
|
35
|
+
|
|
36
|
+
/// Nil for a payload too short for what its flags announce.
|
|
37
|
+
static func parse(_ bytes: [UInt8]) -> CraftHeartRateMeasurement? {
|
|
38
|
+
guard let flags = bytes.first else { return nil }
|
|
39
|
+
var offset = 1
|
|
40
|
+
func uint8() -> Int? {
|
|
41
|
+
guard offset < bytes.count else { return nil }
|
|
42
|
+
defer { offset += 1 }
|
|
43
|
+
return Int(bytes[offset])
|
|
44
|
+
}
|
|
45
|
+
func uint16() -> Int? {
|
|
46
|
+
guard offset + 1 < bytes.count else { return nil }
|
|
47
|
+
defer { offset += 2 }
|
|
48
|
+
return Int(bytes[offset]) | Int(bytes[offset + 1]) << 8
|
|
49
|
+
}
|
|
50
|
+
guard let bpm = flags & 0x01 != 0 ? uint16() : uint8() else { return nil }
|
|
51
|
+
let contact: Bool? = flags & 0x04 != 0 ? flags & 0x02 != 0 : nil
|
|
52
|
+
var energy: Int?
|
|
53
|
+
if flags & 0x08 != 0 {
|
|
54
|
+
guard let value = uint16() else { return nil }
|
|
55
|
+
energy = value
|
|
56
|
+
}
|
|
57
|
+
var intervals: [Double] = []
|
|
58
|
+
if flags & 0x10 != 0 {
|
|
59
|
+
while let value = uint16() { intervals.append(Double(value) * 1000 / 1024) }
|
|
60
|
+
}
|
|
61
|
+
return CraftHeartRateMeasurement(bpm: bpm, sensorContact: contact, energyExpended: energy, rrIntervals: intervals)
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
static func parse(_ data: Data) -> CraftHeartRateMeasurement? { parse([UInt8](data)) }
|
|
65
|
+
|
|
66
|
+
/// The payload a strap would send for these values: the simulator's
|
|
67
|
+
/// stand-in speaks it, so its readings take the same path as a strap's.
|
|
68
|
+
static func encode(bpm: Int, sensorContact: Bool? = true, energyExpended: Int? = nil, rrIntervals: [Double] = []) -> [UInt8] {
|
|
69
|
+
var flags: UInt8 = 0
|
|
70
|
+
var body: [UInt8] = []
|
|
71
|
+
func append16(_ value: Int) { body.append(UInt8(value & 0xFF)); body.append(UInt8((value >> 8) & 0xFF)) }
|
|
72
|
+
if bpm > 255 {
|
|
73
|
+
flags |= 0x01
|
|
74
|
+
append16(bpm)
|
|
75
|
+
} else {
|
|
76
|
+
body.append(UInt8(max(0, bpm)))
|
|
77
|
+
}
|
|
78
|
+
if let contact = sensorContact { flags |= 0x04 | (contact ? 0x02 : 0) }
|
|
79
|
+
if let energy = energyExpended {
|
|
80
|
+
flags |= 0x08
|
|
81
|
+
append16(energy)
|
|
82
|
+
}
|
|
83
|
+
if !rrIntervals.isEmpty {
|
|
84
|
+
flags |= 0x10
|
|
85
|
+
for interval in rrIntervals { append16(Int((interval * 1024 / 1000).rounded())) }
|
|
86
|
+
}
|
|
87
|
+
return [flags] + body
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/// A heart-rate strap, or a watch broadcasting its heart rate, read during a
|
|
92
|
+
/// workout: the page's `craft.heartRate` (CraftApp.swift) and a native
|
|
93
|
+
/// screen's `HeartRate` module (CraftNativeActions.swift), one service for
|
|
94
|
+
/// both.
|
|
95
|
+
///
|
|
96
|
+
/// CoreBluetooth central for the standard Heart Rate Service (0x180D):
|
|
97
|
+
/// - `scan` finds what advertises the service nearby (and a strap the phone
|
|
98
|
+
/// is already connected to, which does not advertise), each one a
|
|
99
|
+
/// `{type: 'device'}` event with its name and signal strength.
|
|
100
|
+
/// - `connect` subscribes to Heart Rate Measurement (0x2A37); every reading
|
|
101
|
+
/// is a `{type: 'reading'}` event with bpm and, when the strap sends them,
|
|
102
|
+
/// RR intervals. The device is remembered, and `reconnect` finds it again
|
|
103
|
+
/// next time without a scan.
|
|
104
|
+
/// - A strap that drops out (out of range, the battery, a sweaty contact) is
|
|
105
|
+
/// asked for again until `disconnect`: CoreBluetooth's pending connect
|
|
106
|
+
/// never times out, so it comes back by itself when the strap does.
|
|
107
|
+
/// - `startRecording` keeps every reading on disk until `stopRecording`, as
|
|
108
|
+
/// the location recording keeps fixes: the readings survive the web view
|
|
109
|
+
/// being suspended with the screen locked (the `bluetooth-central`
|
|
110
|
+
/// background mode keeps them coming) and the app being closed.
|
|
111
|
+
///
|
|
112
|
+
/// Connection changes are `{type: 'state'}` events. All of them reach the
|
|
113
|
+
/// page as `craftHeartRate` and every native screen the same way.
|
|
114
|
+
///
|
|
115
|
+
/// Simulator builds have no Bluetooth; with the `CraftFakeHeartRateMonitor`
|
|
116
|
+
/// default set (`xcrun simctl spawn booted defaults write <bundle id>
|
|
117
|
+
/// CraftFakeHeartRateMonitor -bool YES`) a made-up strap stands in, whose
|
|
118
|
+
/// readings are encoded as 0x2A37 payloads and decoded like a real one's.
|
|
119
|
+
///
|
|
120
|
+
/// Everything runs on main: the central manager delivers there, and every
|
|
121
|
+
/// caller hops there first.
|
|
122
|
+
final class CraftHeartRate: NSObject, CBCentralManagerDelegate, CBPeripheralDelegate {
|
|
123
|
+
typealias Completion = (Result<Any, CraftHeartRateError>) -> Void
|
|
124
|
+
|
|
125
|
+
static let shared = CraftHeartRate()
|
|
126
|
+
|
|
127
|
+
static let serviceUUID = CBUUID(string: "180D")
|
|
128
|
+
static let measurementUUID = CBUUID(string: "2A37")
|
|
129
|
+
/// How long `connect` waits before answering TIMEOUT. The connection is
|
|
130
|
+
/// still asked for after that: a strap only wakes when it is worn.
|
|
131
|
+
static let connectTimeout: TimeInterval = 15
|
|
132
|
+
/// A reading older than this is no longer "now".
|
|
133
|
+
static let staleAfter: TimeInterval = 10
|
|
134
|
+
/// Fourteen hours of one reading a second: more is not one workout.
|
|
135
|
+
static let maxRecordedSamples = 50_000
|
|
136
|
+
|
|
137
|
+
/// The web view's coordinator, which sends an event to the page and every
|
|
138
|
+
/// native screen. False (or unset, in an app without a web view) and the
|
|
139
|
+
/// event goes to the native screens alone.
|
|
140
|
+
var pageSink: (([String: Any]) -> Bool)?
|
|
141
|
+
|
|
142
|
+
private var central: CBCentralManager?
|
|
143
|
+
private var stateWaiters: [() -> Void] = []
|
|
144
|
+
private var discovered: [UUID: (peripheral: CBPeripheral?, name: String, rssi: Int)] = [:]
|
|
145
|
+
private var scanning = false
|
|
146
|
+
private var scanStop: DispatchWorkItem?
|
|
147
|
+
private var peripheral: CBPeripheral?
|
|
148
|
+
private var deviceId: UUID?
|
|
149
|
+
private var deviceName: String?
|
|
150
|
+
private var connection = "disconnected"
|
|
151
|
+
/// Between connect and disconnect: a lost strap is asked for again.
|
|
152
|
+
private var wantsConnection = false
|
|
153
|
+
private var connectWaiters: [Completion] = []
|
|
154
|
+
private var connectDeadline: DispatchWorkItem?
|
|
155
|
+
private var latest: CraftHeartRateMeasurement?
|
|
156
|
+
private var latestAt: Date?
|
|
157
|
+
|
|
158
|
+
private var recordingId: String?
|
|
159
|
+
private var recordingStartedAt: Double?
|
|
160
|
+
private var recordingPaused = false
|
|
161
|
+
private var recordedCount = 0
|
|
162
|
+
private var restored = false
|
|
163
|
+
|
|
164
|
+
private static let rememberedKey = "CraftHeartRate.lastDevice"
|
|
165
|
+
|
|
166
|
+
// MARK: The simulator's stand-in
|
|
167
|
+
|
|
168
|
+
/// A strap that is not there: one device, readings once a second.
|
|
169
|
+
private var simulated: Bool = {
|
|
170
|
+
#if targetEnvironment(simulator)
|
|
171
|
+
return UserDefaults.standard.bool(forKey: "CraftFakeHeartRateMonitor")
|
|
172
|
+
#else
|
|
173
|
+
return false
|
|
174
|
+
#endif
|
|
175
|
+
}()
|
|
176
|
+
private static let simulatedId = UUID(uuidString: "00000000-0000-4000-8000-00000000180D")!
|
|
177
|
+
private var simulatedTimer: Timer?
|
|
178
|
+
private var simulatedBeat = 0
|
|
179
|
+
|
|
180
|
+
// MARK: Entry
|
|
181
|
+
|
|
182
|
+
/// One call from a page or a native screen, by method name.
|
|
183
|
+
func perform(method: String, args: [Any], completion: @escaping Completion) {
|
|
184
|
+
let options = args.first as? [String: Any] ?? [:]
|
|
185
|
+
DispatchQueue.main.async {
|
|
186
|
+
self.restoreRecording()
|
|
187
|
+
switch method {
|
|
188
|
+
case "getState": completion(.success(self.state()))
|
|
189
|
+
case "requestAuthorization": self.requestAuthorization(completion: completion)
|
|
190
|
+
case "scan": self.scan(seconds: (options["timeout"] as? NSNumber)?.doubleValue, completion: completion)
|
|
191
|
+
case "stopScan":
|
|
192
|
+
self.stopScan()
|
|
193
|
+
completion(.success(self.state()))
|
|
194
|
+
case "getDevices": completion(.success(self.devices()))
|
|
195
|
+
case "connect": self.connect(id: options["id"] as? String, wait: true, completion: completion)
|
|
196
|
+
case "reconnect": self.connect(id: nil, wait: false, completion: completion)
|
|
197
|
+
case "disconnect": self.disconnect(forget: options["forget"] as? Bool ?? false, completion: completion)
|
|
198
|
+
case "startRecording": self.startRecording(completion: completion)
|
|
199
|
+
case "pauseRecording": self.setRecordingPaused(true, completion: completion)
|
|
200
|
+
case "resumeRecording": self.setRecordingPaused(false, completion: completion)
|
|
201
|
+
case "stopRecording": self.stopRecording(completion: completion)
|
|
202
|
+
case "getRecordingState": completion(.success(self.recordingSummary(count: true)))
|
|
203
|
+
case "readRecording": completion(.success(self.loadSamples(since: (options["since"] as? NSNumber)?.doubleValue)))
|
|
204
|
+
default: completion(.failure(CraftHeartRateError(message: "Unsupported HeartRate method", code: "UNKNOWN_ACTION")))
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// MARK: State
|
|
210
|
+
|
|
211
|
+
static func authorizationName(_ value: CBManagerAuthorization) -> String {
|
|
212
|
+
switch value {
|
|
213
|
+
case .allowedAlways: return "authorized"
|
|
214
|
+
case .denied: return "denied"
|
|
215
|
+
case .restricted: return "restricted"
|
|
216
|
+
case .notDetermined: return "notDetermined"
|
|
217
|
+
@unknown default: return "denied"
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
static func bluetoothName(_ value: CBManagerState) -> String {
|
|
222
|
+
switch value {
|
|
223
|
+
case .poweredOn: return "poweredOn"
|
|
224
|
+
case .poweredOff: return "poweredOff"
|
|
225
|
+
case .unauthorized: return "unauthorized"
|
|
226
|
+
case .unsupported: return "unsupported"
|
|
227
|
+
case .resetting: return "resetting"
|
|
228
|
+
case .unknown: return "unknown"
|
|
229
|
+
@unknown default: return "unknown"
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
private var authorization: String { simulated ? "authorized" : Self.authorizationName(CBManager.authorization) }
|
|
234
|
+
|
|
235
|
+
/// "unknown" until something has asked for Bluetooth: reading it would
|
|
236
|
+
/// make the central manager, and that is what shows iOS's prompt.
|
|
237
|
+
private var bluetooth: String {
|
|
238
|
+
if simulated { return "poweredOn" }
|
|
239
|
+
guard let central else { return "unknown" }
|
|
240
|
+
return Self.bluetoothName(central.state)
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
private func remembered() -> [String: Any]? {
|
|
244
|
+
guard let device = UserDefaults.standard.dictionary(forKey: Self.rememberedKey),
|
|
245
|
+
device["id"] is String else { return nil }
|
|
246
|
+
return device
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
private func remember(id: UUID, name: String) {
|
|
250
|
+
UserDefaults.standard.set(["id": id.uuidString, "name": name], forKey: Self.rememberedKey)
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
func state() -> [String: Any] {
|
|
254
|
+
let fresh = latestAt.map { Date().timeIntervalSince($0) <= Self.staleAfter } ?? false
|
|
255
|
+
let device: Any = deviceId.map { ["id": $0.uuidString, "name": deviceName ?? "Heart rate monitor"] as [String: Any] } ?? NSNull()
|
|
256
|
+
return [
|
|
257
|
+
"available": bluetooth != "unsupported",
|
|
258
|
+
"bluetooth": bluetooth,
|
|
259
|
+
"authorization": authorization,
|
|
260
|
+
"scanning": scanning,
|
|
261
|
+
"connection": connection,
|
|
262
|
+
"device": device,
|
|
263
|
+
"lastDevice": remembered() ?? NSNull(),
|
|
264
|
+
"bpm": (fresh ? latest?.bpm : nil) ?? NSNull(),
|
|
265
|
+
"lastReadingAt": latestAt.map { $0.timeIntervalSince1970 * 1000 } ?? NSNull(),
|
|
266
|
+
"recording": recordingSummary(count: false),
|
|
267
|
+
]
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
private func emit(_ detail: [String: Any]) {
|
|
271
|
+
if pageSink?(detail) != true { CraftWebServices.forward("craftHeartRate", data: detail) }
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
private func emitState() {
|
|
275
|
+
var detail = state()
|
|
276
|
+
detail["type"] = "state"
|
|
277
|
+
emit(detail)
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// MARK: Bluetooth
|
|
281
|
+
|
|
282
|
+
/// The central manager, made on first use: that is when iOS asks.
|
|
283
|
+
private func whenReady(_ run: @escaping () -> Void) {
|
|
284
|
+
if simulated { return run() }
|
|
285
|
+
if central == nil {
|
|
286
|
+
central = CBCentralManager(delegate: self, queue: nil, options: [CBCentralManagerOptionShowPowerAlertKey: false])
|
|
287
|
+
}
|
|
288
|
+
if central!.state == .unknown || central!.state == .resetting {
|
|
289
|
+
stateWaiters.append(run)
|
|
290
|
+
} else {
|
|
291
|
+
run()
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/// A reason the radio cannot be used now, or nil when it can.
|
|
296
|
+
private func unusable() -> CraftHeartRateError? {
|
|
297
|
+
switch bluetooth {
|
|
298
|
+
case "poweredOn": return nil
|
|
299
|
+
case "poweredOff": return CraftHeartRateError(message: "Bluetooth is off", code: "BLUETOOTH_OFF")
|
|
300
|
+
case "unauthorized": return CraftHeartRateError(message: "Bluetooth access was not allowed", code: "NOT_AUTHORIZED")
|
|
301
|
+
case "unsupported": return CraftHeartRateError(message: "This device has no Bluetooth", code: "UNSUPPORTED")
|
|
302
|
+
default: return CraftHeartRateError(message: "Bluetooth is not ready", code: "BLUETOOTH_UNAVAILABLE")
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
private func requestAuthorization(completion: @escaping Completion) {
|
|
307
|
+
whenReady { completion(.success(self.authorization)) }
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
func centralManagerDidUpdateState(_ central: CBCentralManager) {
|
|
311
|
+
let waiting = stateWaiters
|
|
312
|
+
if central.state != .unknown && central.state != .resetting {
|
|
313
|
+
stateWaiters.removeAll()
|
|
314
|
+
waiting.forEach { $0() }
|
|
315
|
+
}
|
|
316
|
+
if central.state == .poweredOn {
|
|
317
|
+
// Bluetooth back on (or on at last): ask for the strap again.
|
|
318
|
+
if wantsConnection, let peripheral, peripheral.state == .disconnected {
|
|
319
|
+
connection = "reconnecting"
|
|
320
|
+
central.connect(peripheral)
|
|
321
|
+
}
|
|
322
|
+
} else if central.state != .unknown {
|
|
323
|
+
scanning = false
|
|
324
|
+
if wantsConnection && connection != "disconnected" { connection = "reconnecting" }
|
|
325
|
+
}
|
|
326
|
+
emitState()
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
// MARK: Scanning
|
|
330
|
+
|
|
331
|
+
private func devices() -> [[String: Any]] {
|
|
332
|
+
discovered
|
|
333
|
+
.map { id, device in ["id": id.uuidString, "name": device.name, "rssi": device.rssi] as [String: Any] }
|
|
334
|
+
.sorted { ($0["rssi"] as? Int ?? -200) > ($1["rssi"] as? Int ?? -200) }
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
private func found(id: UUID, peripheral: CBPeripheral?, name: String, rssi: Int) {
|
|
338
|
+
let known = discovered[id] != nil
|
|
339
|
+
discovered[id] = (peripheral, name, rssi)
|
|
340
|
+
if !known { emit(["type": "device", "device": ["id": id.uuidString, "name": name, "rssi": rssi]]) }
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
private func scan(seconds: Double?, completion: @escaping Completion) {
|
|
344
|
+
whenReady {
|
|
345
|
+
if let reason = self.unusable() { return completion(.failure(reason)) }
|
|
346
|
+
self.discovered.removeAll()
|
|
347
|
+
self.scanning = true
|
|
348
|
+
if self.simulated {
|
|
349
|
+
self.found(id: Self.simulatedId, peripheral: nil, name: "Simulated HRM", rssi: -48)
|
|
350
|
+
} else if let central = self.central {
|
|
351
|
+
// A strap already connected to this phone (by Settings or
|
|
352
|
+
// another app) no longer advertises; it is still a choice.
|
|
353
|
+
for peripheral in central.retrieveConnectedPeripherals(withServices: [Self.serviceUUID]) {
|
|
354
|
+
self.found(id: peripheral.identifier, peripheral: peripheral, name: peripheral.name ?? "Heart rate monitor", rssi: 0)
|
|
355
|
+
}
|
|
356
|
+
central.scanForPeripherals(withServices: [Self.serviceUUID], options: [CBCentralManagerScanOptionAllowDuplicatesKey: false])
|
|
357
|
+
}
|
|
358
|
+
self.scanStop?.cancel()
|
|
359
|
+
let stop = DispatchWorkItem { [weak self] in self?.stopScan() }
|
|
360
|
+
self.scanStop = stop
|
|
361
|
+
DispatchQueue.main.asyncAfter(deadline: .now() + min(max(seconds ?? 15, 1), 60), execute: stop)
|
|
362
|
+
self.emitState()
|
|
363
|
+
completion(.success(self.state()))
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
private func stopScan() {
|
|
368
|
+
scanStop?.cancel()
|
|
369
|
+
scanStop = nil
|
|
370
|
+
guard scanning else { return }
|
|
371
|
+
scanning = false
|
|
372
|
+
if central?.state == .poweredOn { central?.stopScan() }
|
|
373
|
+
emitState()
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
func centralManager(_ central: CBCentralManager, didDiscover peripheral: CBPeripheral, advertisementData: [String: Any], rssi RSSI: NSNumber) {
|
|
377
|
+
let name = (advertisementData[CBAdvertisementDataLocalNameKey] as? String) ?? peripheral.name ?? "Heart rate monitor"
|
|
378
|
+
// 127 is CoreBluetooth's "no reading".
|
|
379
|
+
found(id: peripheral.identifier, peripheral: peripheral, name: name, rssi: RSSI.intValue == 127 ? -100 : RSSI.intValue)
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
// MARK: Connecting
|
|
383
|
+
|
|
384
|
+
private func connect(id: String?, wait: Bool, completion: @escaping Completion) {
|
|
385
|
+
let target = id ?? remembered()?["id"] as? String
|
|
386
|
+
guard let target, let uuid = UUID(uuidString: target) else {
|
|
387
|
+
if wait { return completion(.failure(CraftHeartRateError(message: "connect needs the id of a device from scan", code: "INVALID_ARGUMENT"))) }
|
|
388
|
+
return completion(.success(state()))
|
|
389
|
+
}
|
|
390
|
+
whenReady {
|
|
391
|
+
if let reason = self.unusable() { return completion(.failure(reason)) }
|
|
392
|
+
// Already there, or on the way: nothing to start again.
|
|
393
|
+
if self.deviceId == uuid && self.wantsConnection {
|
|
394
|
+
if self.connection == "connected" || !wait { return completion(.success(self.state())) }
|
|
395
|
+
self.connectWaiters.append(completion)
|
|
396
|
+
self.armConnectDeadline()
|
|
397
|
+
return
|
|
398
|
+
}
|
|
399
|
+
self.release()
|
|
400
|
+
let saved = self.remembered()
|
|
401
|
+
let savedName = (saved?["id"] as? String) == target ? saved?["name"] as? String : nil
|
|
402
|
+
let name = self.discovered[uuid]?.name ?? savedName ?? "Heart rate monitor"
|
|
403
|
+
if self.simulated {
|
|
404
|
+
guard uuid == Self.simulatedId else { return completion(.failure(CraftHeartRateError(message: "No heart-rate monitor with that id is nearby", code: "NOT_FOUND"))) }
|
|
405
|
+
} else {
|
|
406
|
+
let peripheral = self.discovered[uuid]?.peripheral ?? self.central?.retrievePeripherals(withIdentifiers: [uuid]).first
|
|
407
|
+
guard let peripheral else { return completion(.failure(CraftHeartRateError(message: "No heart-rate monitor with that id is nearby", code: "NOT_FOUND"))) }
|
|
408
|
+
peripheral.delegate = self
|
|
409
|
+
self.peripheral = peripheral
|
|
410
|
+
}
|
|
411
|
+
self.deviceId = uuid
|
|
412
|
+
self.deviceName = name
|
|
413
|
+
self.wantsConnection = true
|
|
414
|
+
self.connection = "connecting"
|
|
415
|
+
self.remember(id: uuid, name: name)
|
|
416
|
+
if wait {
|
|
417
|
+
self.connectWaiters.append(completion)
|
|
418
|
+
self.armConnectDeadline()
|
|
419
|
+
}
|
|
420
|
+
if self.simulated {
|
|
421
|
+
DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { [weak self] in self?.simulatedConnected() }
|
|
422
|
+
} else if let peripheral = self.peripheral {
|
|
423
|
+
self.central?.connect(peripheral)
|
|
424
|
+
}
|
|
425
|
+
self.emitState()
|
|
426
|
+
if !wait { completion(.success(self.state())) }
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
/// `connect` answers TIMEOUT after a while; the strap is still asked
|
|
431
|
+
/// for, since it may wake up once it is worn.
|
|
432
|
+
private func armConnectDeadline() {
|
|
433
|
+
guard connectDeadline == nil else { return }
|
|
434
|
+
let deadline = DispatchWorkItem { [weak self] in
|
|
435
|
+
guard let self, self.connection != "connected" else { return }
|
|
436
|
+
self.settleConnect(.failure(CraftHeartRateError(message: "The heart-rate monitor did not answer. Wear it to wake it up.", code: "TIMEOUT")))
|
|
437
|
+
}
|
|
438
|
+
connectDeadline = deadline
|
|
439
|
+
DispatchQueue.main.asyncAfter(deadline: .now() + Self.connectTimeout, execute: deadline)
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
private func settleConnect(_ result: Result<Any, CraftHeartRateError>) {
|
|
443
|
+
connectDeadline?.cancel()
|
|
444
|
+
connectDeadline = nil
|
|
445
|
+
let waiting = connectWaiters
|
|
446
|
+
connectWaiters.removeAll()
|
|
447
|
+
waiting.forEach { $0(result) }
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
/// Lets go of the current strap without telling anyone.
|
|
451
|
+
private func release() {
|
|
452
|
+
wantsConnection = false
|
|
453
|
+
simulatedTimer?.invalidate()
|
|
454
|
+
simulatedTimer = nil
|
|
455
|
+
if let peripheral, let central, peripheral.state != .disconnected { central.cancelPeripheralConnection(peripheral) }
|
|
456
|
+
peripheral = nil
|
|
457
|
+
deviceId = nil
|
|
458
|
+
deviceName = nil
|
|
459
|
+
latest = nil
|
|
460
|
+
latestAt = nil
|
|
461
|
+
connection = "disconnected"
|
|
462
|
+
settleConnect(.failure(CraftHeartRateError(message: "Another heart-rate monitor was chosen", code: "CANCELLED")))
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
private func disconnect(forget: Bool, completion: @escaping Completion) {
|
|
466
|
+
release()
|
|
467
|
+
if forget { UserDefaults.standard.removeObject(forKey: Self.rememberedKey) }
|
|
468
|
+
emitState()
|
|
469
|
+
completion(.success(state()))
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
func centralManager(_ central: CBCentralManager, didConnect peripheral: CBPeripheral) {
|
|
473
|
+
guard peripheral.identifier == deviceId else { return }
|
|
474
|
+
peripheral.delegate = self
|
|
475
|
+
peripheral.discoverServices([Self.serviceUUID])
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
func centralManager(_ central: CBCentralManager, didFailToConnect peripheral: CBPeripheral, error: Error?) {
|
|
479
|
+
guard peripheral.identifier == deviceId, wantsConnection else { return }
|
|
480
|
+
// Asked for again: a failed attempt is usually a strap going to sleep.
|
|
481
|
+
connection = "reconnecting"
|
|
482
|
+
central.connect(peripheral)
|
|
483
|
+
emitState()
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
func centralManager(_ central: CBCentralManager, didDisconnectPeripheral peripheral: CBPeripheral, error: Error?) {
|
|
487
|
+
guard peripheral.identifier == deviceId else { return }
|
|
488
|
+
latest = nil
|
|
489
|
+
latestAt = nil
|
|
490
|
+
if wantsConnection {
|
|
491
|
+
connection = "reconnecting"
|
|
492
|
+
central.connect(peripheral)
|
|
493
|
+
} else {
|
|
494
|
+
connection = "disconnected"
|
|
495
|
+
}
|
|
496
|
+
emitState()
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
func peripheral(_ peripheral: CBPeripheral, didDiscoverServices error: Error?) {
|
|
500
|
+
guard let service = peripheral.services?.first(where: { $0.uuid == Self.serviceUUID }) else {
|
|
501
|
+
notAHeartRateMonitor(peripheral)
|
|
502
|
+
return
|
|
503
|
+
}
|
|
504
|
+
peripheral.discoverCharacteristics([Self.measurementUUID], for: service)
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
func peripheral(_ peripheral: CBPeripheral, didDiscoverCharacteristicsFor service: CBService, error: Error?) {
|
|
508
|
+
guard let measurement = service.characteristics?.first(where: { $0.uuid == Self.measurementUUID }) else {
|
|
509
|
+
notAHeartRateMonitor(peripheral)
|
|
510
|
+
return
|
|
511
|
+
}
|
|
512
|
+
peripheral.setNotifyValue(true, for: measurement)
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
func peripheral(_ peripheral: CBPeripheral, didUpdateNotificationStateFor characteristic: CBCharacteristic, error: Error?) {
|
|
516
|
+
guard characteristic.uuid == Self.measurementUUID, peripheral.identifier == deviceId else { return }
|
|
517
|
+
if characteristic.isNotifying {
|
|
518
|
+
connection = "connected"
|
|
519
|
+
emitState()
|
|
520
|
+
settleConnect(.success(state()))
|
|
521
|
+
} else if let error {
|
|
522
|
+
settleConnect(.failure(CraftHeartRateError(message: error.localizedDescription, code: "CONNECT_FAILED")))
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
func peripheral(_ peripheral: CBPeripheral, didUpdateValueFor characteristic: CBCharacteristic, error: Error?) {
|
|
527
|
+
guard characteristic.uuid == Self.measurementUUID, peripheral.identifier == deviceId, let value = characteristic.value else { return }
|
|
528
|
+
received([UInt8](value))
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
private func notAHeartRateMonitor(_ peripheral: CBPeripheral) {
|
|
532
|
+
wantsConnection = false
|
|
533
|
+
central?.cancelPeripheralConnection(peripheral)
|
|
534
|
+
connection = "disconnected"
|
|
535
|
+
settleConnect(.failure(CraftHeartRateError(message: "That device does not send a heart rate", code: "NOT_HEART_RATE_MONITOR")))
|
|
536
|
+
emitState()
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
// MARK: Readings
|
|
540
|
+
|
|
541
|
+
private func received(_ bytes: [UInt8]) {
|
|
542
|
+
// A strap reads 0 between losing the skin and giving up; that is no
|
|
543
|
+
// heart rate, and neither is anything a heart could not do.
|
|
544
|
+
guard let reading = CraftHeartRateMeasurement.parse(bytes), reading.bpm >= 20, reading.bpm <= 250 else { return }
|
|
545
|
+
let now = Date()
|
|
546
|
+
latest = reading
|
|
547
|
+
latestAt = now
|
|
548
|
+
let timestamp = (now.timeIntervalSince1970 * 1000).rounded()
|
|
549
|
+
let intervals = reading.rrIntervals.map { ($0 * 10).rounded() / 10 }
|
|
550
|
+
var detail: [String: Any] = [
|
|
551
|
+
"type": "reading",
|
|
552
|
+
"bpm": reading.bpm,
|
|
553
|
+
"rrIntervals": intervals,
|
|
554
|
+
"contact": reading.sensorContact ?? NSNull(),
|
|
555
|
+
"energyExpended": reading.energyExpended ?? NSNull(),
|
|
556
|
+
"timestamp": timestamp,
|
|
557
|
+
]
|
|
558
|
+
if let deviceId { detail["deviceId"] = deviceId.uuidString }
|
|
559
|
+
appendSample(["timestamp": timestamp, "bpm": reading.bpm, "rr": intervals])
|
|
560
|
+
emit(detail)
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
private func simulatedConnected() {
|
|
564
|
+
guard simulated, wantsConnection, deviceId == Self.simulatedId else { return }
|
|
565
|
+
connection = "connected"
|
|
566
|
+
emitState()
|
|
567
|
+
settleConnect(.success(state()))
|
|
568
|
+
simulatedTimer?.invalidate()
|
|
569
|
+
simulatedTimer = Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { [weak self] _ in
|
|
570
|
+
guard let self else { return }
|
|
571
|
+
self.simulatedBeat += 1
|
|
572
|
+
// A steady run with a slow swell, the way a real one drifts.
|
|
573
|
+
let bpm = 142 + Int((sin(Double(self.simulatedBeat) / 20) * 12).rounded())
|
|
574
|
+
let interval = 60_000 / Double(bpm)
|
|
575
|
+
self.received(CraftHeartRateMeasurement.encode(bpm: bpm, sensorContact: true, rrIntervals: [interval]))
|
|
576
|
+
}
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
// MARK: Recording
|
|
580
|
+
|
|
581
|
+
private var directory: URL {
|
|
582
|
+
FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0]
|
|
583
|
+
}
|
|
584
|
+
private var stateURL: URL { directory.appendingPathComponent("craft-heart-rate-recording-state.json") }
|
|
585
|
+
private var samplesURL: URL { directory.appendingPathComponent("craft-heart-rate-recording.jsonl") }
|
|
586
|
+
|
|
587
|
+
/// A recording that was running when the app last closed carries on.
|
|
588
|
+
private func restoreRecording() {
|
|
589
|
+
guard !restored else { return }
|
|
590
|
+
restored = true
|
|
591
|
+
guard let data = try? Data(contentsOf: stateURL),
|
|
592
|
+
let saved = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
|
|
593
|
+
saved["active"] as? Bool == true else { return }
|
|
594
|
+
recordingId = saved["id"] as? String
|
|
595
|
+
recordingStartedAt = saved["startedAt"] as? Double
|
|
596
|
+
recordingPaused = saved["paused"] as? Bool ?? false
|
|
597
|
+
recordedCount = loadSamples(since: nil).count
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
private func persistRecording() {
|
|
601
|
+
let saved: [String: Any] = [
|
|
602
|
+
"active": recordingId != nil,
|
|
603
|
+
"paused": recordingPaused,
|
|
604
|
+
"id": recordingId ?? NSNull(),
|
|
605
|
+
"startedAt": recordingStartedAt ?? NSNull(),
|
|
606
|
+
]
|
|
607
|
+
do {
|
|
608
|
+
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
|
|
609
|
+
try JSONSerialization.data(withJSONObject: saved).write(to: stateURL, options: .atomic)
|
|
610
|
+
protect(stateURL)
|
|
611
|
+
} catch {
|
|
612
|
+
print("Unable to persist the heart-rate recording state: \(error)")
|
|
613
|
+
}
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
private func protect(_ url: URL) {
|
|
617
|
+
try? FileManager.default.setAttributes([.protectionKey: FileProtectionType.completeUntilFirstUserAuthentication], ofItemAtPath: url.path)
|
|
618
|
+
var values = URLResourceValues()
|
|
619
|
+
values.isExcludedFromBackup = true
|
|
620
|
+
var protectedURL = url
|
|
621
|
+
try? protectedURL.setResourceValues(values)
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
private func recordingSummary(count: Bool) -> [String: Any] {
|
|
625
|
+
var summary: [String: Any] = [
|
|
626
|
+
"id": recordingId ?? NSNull(),
|
|
627
|
+
"active": recordingId != nil,
|
|
628
|
+
"paused": recordingPaused,
|
|
629
|
+
"startedAt": recordingStartedAt ?? NSNull(),
|
|
630
|
+
]
|
|
631
|
+
if count { summary["sampleCount"] = recordedCount }
|
|
632
|
+
return summary
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
private func startRecording(completion: @escaping Completion) {
|
|
636
|
+
do {
|
|
637
|
+
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
|
|
638
|
+
try Data().write(to: samplesURL, options: .atomic)
|
|
639
|
+
protect(samplesURL)
|
|
640
|
+
} catch {
|
|
641
|
+
return completion(.failure(CraftHeartRateError(message: "Unable to create the heart-rate recording", code: "RECORDING_STORAGE_ERROR")))
|
|
642
|
+
}
|
|
643
|
+
recordingId = UUID().uuidString
|
|
644
|
+
recordingStartedAt = (Date().timeIntervalSince1970 * 1000).rounded()
|
|
645
|
+
recordingPaused = false
|
|
646
|
+
recordedCount = 0
|
|
647
|
+
persistRecording()
|
|
648
|
+
completion(.success(recordingSummary(count: true)))
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
private func setRecordingPaused(_ paused: Bool, completion: @escaping Completion) {
|
|
652
|
+
guard recordingId != nil else {
|
|
653
|
+
return completion(.failure(CraftHeartRateError(message: "No active heart-rate recording", code: "NO_ACTIVE_RECORDING")))
|
|
654
|
+
}
|
|
655
|
+
recordingPaused = paused
|
|
656
|
+
persistRecording()
|
|
657
|
+
completion(.success(recordingSummary(count: true)))
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
private func stopRecording(completion: @escaping Completion) {
|
|
661
|
+
var summary = recordingSummary(count: true)
|
|
662
|
+
summary["active"] = false
|
|
663
|
+
summary["samples"] = loadSamples(since: nil)
|
|
664
|
+
recordingId = nil
|
|
665
|
+
recordingStartedAt = nil
|
|
666
|
+
recordingPaused = false
|
|
667
|
+
recordedCount = 0
|
|
668
|
+
persistRecording()
|
|
669
|
+
try? FileManager.default.removeItem(at: samplesURL)
|
|
670
|
+
completion(.success(summary))
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
private func appendSample(_ sample: [String: Any]) {
|
|
674
|
+
guard recordingId != nil, !recordingPaused, recordedCount < Self.maxRecordedSamples,
|
|
675
|
+
let json = try? JSONSerialization.data(withJSONObject: sample) else { return }
|
|
676
|
+
var line = json
|
|
677
|
+
line.append(0x0A)
|
|
678
|
+
do {
|
|
679
|
+
let handle = try FileHandle(forWritingTo: samplesURL)
|
|
680
|
+
try handle.seekToEnd()
|
|
681
|
+
try handle.write(contentsOf: line)
|
|
682
|
+
try handle.close()
|
|
683
|
+
recordedCount += 1
|
|
684
|
+
} catch {
|
|
685
|
+
print("Unable to append a heart-rate sample: \(error)")
|
|
686
|
+
}
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
/// The recording's samples, oldest first; only those after `since` (ms) when given.
|
|
690
|
+
private func loadSamples(since: Double?) -> [[String: Any]] {
|
|
691
|
+
guard let text = try? String(contentsOf: samplesURL, encoding: .utf8) else { return [] }
|
|
692
|
+
return text.split(separator: "\n").compactMap { line in
|
|
693
|
+
guard let data = line.data(using: .utf8),
|
|
694
|
+
let sample = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { return nil }
|
|
695
|
+
if let since, ((sample["timestamp"] as? NSNumber)?.doubleValue ?? 0) <= since { return nil }
|
|
696
|
+
return sample
|
|
697
|
+
}
|
|
698
|
+
}
|
|
699
|
+
}
|
|
@@ -61,6 +61,9 @@ enum CraftNativeActions {
|
|
|
61
61
|
if config.enableGeolocation && config.renderer != "native" { values.append("geolocation") }
|
|
62
62
|
// The Music app's song and transport are the coordinator's too.
|
|
63
63
|
if config.enableAppleMusic && config.renderer != "native" { values.append("music") }
|
|
64
|
+
// A heart-rate strap is the app's own service (CraftHeartRate.swift),
|
|
65
|
+
// so it needs no web view.
|
|
66
|
+
if config.enableHeartRate { values.append("heartRate") }
|
|
64
67
|
return values
|
|
65
68
|
}
|
|
66
69
|
|
|
@@ -290,6 +293,19 @@ enum CraftNativeActions {
|
|
|
290
293
|
}
|
|
291
294
|
CraftWebServices.perform(action: action, body: args.first as? [String: Any] ?? [:]) { complete(requestToken, $0, completion) }
|
|
292
295
|
return
|
|
296
|
+
case ("HeartRate", _):
|
|
297
|
+
// A Bluetooth heart-rate strap, the same service as a page's
|
|
298
|
+
// `craft.heartRate`: one connection and one recording, whichever
|
|
299
|
+
// side started them. Its events reach every screen as
|
|
300
|
+
// `craftHeartRate`.
|
|
301
|
+
guard config.enableHeartRate else {
|
|
302
|
+
complete(requestToken, failure("CAPABILITY_DISABLED", "Heart rate is disabled"), completion)
|
|
303
|
+
return
|
|
304
|
+
}
|
|
305
|
+
CraftHeartRate.shared.perform(method: method, args: args) { result in
|
|
306
|
+
complete(requestToken, result.mapError { CraftNativeActionError(code: $0.code, message: $0.message) }, completion)
|
|
307
|
+
}
|
|
308
|
+
return
|
|
293
309
|
case ("Linking", "openURL"), ("Linking", "canOpenURL"):
|
|
294
310
|
// Another app or the system: `webcal:` subscriptions, `mailto:`,
|
|
295
311
|
// `tel:`, the App Store, this app's page in Settings
|
package/dist/mobile.js
CHANGED
|
@@ -823,6 +823,164 @@ var music = {
|
|
|
823
823
|
};
|
|
824
824
|
}
|
|
825
825
|
};
|
|
826
|
+
var HR_AUTHORIZATIONS = ["authorized", "denied", "restricted", "notDetermined", "unavailable"];
|
|
827
|
+
var HR_BLUETOOTH = ["poweredOn", "poweredOff", "unauthorized", "unsupported", "resetting", "unknown"];
|
|
828
|
+
var HR_CONNECTIONS = ["disconnected", "connecting", "connected", "reconnecting"];
|
|
829
|
+
function hrRecord(value) {
|
|
830
|
+
return value && typeof value === "object" && !Array.isArray(value) ? value : {};
|
|
831
|
+
}
|
|
832
|
+
function hrNumber(value) {
|
|
833
|
+
return typeof value === "number" && Number.isFinite(value) ? value : null;
|
|
834
|
+
}
|
|
835
|
+
function hrBpm(value) {
|
|
836
|
+
const bpm = hrNumber(value);
|
|
837
|
+
return bpm !== null && bpm >= 20 && bpm <= 250 ? Math.round(bpm) : null;
|
|
838
|
+
}
|
|
839
|
+
function hrIntervals(value) {
|
|
840
|
+
return Array.isArray(value) ? value.filter((ms) => typeof ms === "number" && Number.isFinite(ms) && ms > 0 && ms < 5000) : [];
|
|
841
|
+
}
|
|
842
|
+
function normalizeHeartRateDevice(value) {
|
|
843
|
+
const raw = hrRecord(value);
|
|
844
|
+
if (typeof raw.id !== "string" || !raw.id)
|
|
845
|
+
return null;
|
|
846
|
+
return { id: raw.id, name: typeof raw.name === "string" && raw.name.trim() ? raw.name : "Heart rate monitor", rssi: hrNumber(raw.rssi) };
|
|
847
|
+
}
|
|
848
|
+
function normalizeHeartRateRecording(value) {
|
|
849
|
+
const raw = hrRecord(value);
|
|
850
|
+
return {
|
|
851
|
+
id: typeof raw.id === "string" ? raw.id : null,
|
|
852
|
+
active: raw.active === true,
|
|
853
|
+
paused: raw.paused === true,
|
|
854
|
+
startedAt: hrNumber(raw.startedAt),
|
|
855
|
+
sampleCount: hrNumber(raw.sampleCount)
|
|
856
|
+
};
|
|
857
|
+
}
|
|
858
|
+
function normalizeHeartRateState(value) {
|
|
859
|
+
const raw = hrRecord(value);
|
|
860
|
+
return {
|
|
861
|
+
available: raw.available !== false && Object.keys(raw).length > 0,
|
|
862
|
+
bluetooth: HR_BLUETOOTH.includes(raw.bluetooth) ? raw.bluetooth : "unknown",
|
|
863
|
+
authorization: HR_AUTHORIZATIONS.includes(raw.authorization) ? raw.authorization : "unavailable",
|
|
864
|
+
scanning: raw.scanning === true,
|
|
865
|
+
connection: HR_CONNECTIONS.includes(raw.connection) ? raw.connection : "disconnected",
|
|
866
|
+
device: normalizeHeartRateDevice(raw.device),
|
|
867
|
+
lastDevice: normalizeHeartRateDevice(raw.lastDevice),
|
|
868
|
+
bpm: hrBpm(raw.bpm),
|
|
869
|
+
lastReadingAt: hrNumber(raw.lastReadingAt),
|
|
870
|
+
recording: normalizeHeartRateRecording(raw.recording)
|
|
871
|
+
};
|
|
872
|
+
}
|
|
873
|
+
function normalizeHeartRateReading(value) {
|
|
874
|
+
const raw = hrRecord(value);
|
|
875
|
+
const bpm = hrBpm(raw.bpm);
|
|
876
|
+
if (bpm === null)
|
|
877
|
+
return null;
|
|
878
|
+
return {
|
|
879
|
+
bpm,
|
|
880
|
+
rrIntervals: hrIntervals(raw.rrIntervals),
|
|
881
|
+
contact: typeof raw.contact === "boolean" ? raw.contact : null,
|
|
882
|
+
energyExpended: hrNumber(raw.energyExpended),
|
|
883
|
+
timestamp: hrNumber(raw.timestamp) ?? Date.now(),
|
|
884
|
+
deviceId: typeof raw.deviceId === "string" ? raw.deviceId : null
|
|
885
|
+
};
|
|
886
|
+
}
|
|
887
|
+
function normalizeHeartRateSamples(value) {
|
|
888
|
+
if (!Array.isArray(value))
|
|
889
|
+
return [];
|
|
890
|
+
const seen = new Set;
|
|
891
|
+
const samples = [];
|
|
892
|
+
for (const item of value) {
|
|
893
|
+
const raw = hrRecord(item);
|
|
894
|
+
const timestamp = hrNumber(raw.timestamp);
|
|
895
|
+
const bpm = hrBpm(raw.bpm);
|
|
896
|
+
if (timestamp === null || bpm === null || seen.has(timestamp))
|
|
897
|
+
continue;
|
|
898
|
+
seen.add(timestamp);
|
|
899
|
+
const rr = hrIntervals(raw.rr);
|
|
900
|
+
samples.push(rr.length ? { timestamp, bpm, rr } : { timestamp, bpm });
|
|
901
|
+
}
|
|
902
|
+
return samples.sort((a, b) => a.timestamp - b.timestamp);
|
|
903
|
+
}
|
|
904
|
+
function noHeartRate() {
|
|
905
|
+
return normalizeHeartRateState({ available: false });
|
|
906
|
+
}
|
|
907
|
+
function nativeHeartRate() {
|
|
908
|
+
const craft = getCraftRoot();
|
|
909
|
+
if (!craft?.heartRate || craft.capabilities?.heartRate === false)
|
|
910
|
+
return;
|
|
911
|
+
return craft.heartRate;
|
|
912
|
+
}
|
|
913
|
+
var heartRateWatch = { readings: new Set, devices: new Set, states: new Set, stop: null };
|
|
914
|
+
function hrNotify(listeners, value) {
|
|
915
|
+
for (const listener of [...listeners]) {
|
|
916
|
+
try {
|
|
917
|
+
listener(value);
|
|
918
|
+
} catch {}
|
|
919
|
+
}
|
|
920
|
+
}
|
|
921
|
+
function hrSubscribe(set, callback) {
|
|
922
|
+
if (!nativeHeartRate() || typeof window === "undefined")
|
|
923
|
+
return () => {};
|
|
924
|
+
set.add(callback);
|
|
925
|
+
if (!heartRateWatch.stop) {
|
|
926
|
+
const onEvent = (event) => {
|
|
927
|
+
const detail = hrRecord(event.detail);
|
|
928
|
+
if (detail.type === "reading") {
|
|
929
|
+
const reading = normalizeHeartRateReading(detail);
|
|
930
|
+
if (reading)
|
|
931
|
+
hrNotify(heartRateWatch.readings, reading);
|
|
932
|
+
} else if (detail.type === "device") {
|
|
933
|
+
const device2 = normalizeHeartRateDevice(detail.device);
|
|
934
|
+
if (device2)
|
|
935
|
+
hrNotify(heartRateWatch.devices, device2);
|
|
936
|
+
} else if (detail.type === "state") {
|
|
937
|
+
hrNotify(heartRateWatch.states, normalizeHeartRateState(detail));
|
|
938
|
+
}
|
|
939
|
+
};
|
|
940
|
+
window.addEventListener("craftHeartRate", onEvent);
|
|
941
|
+
heartRateWatch.stop = () => window.removeEventListener("craftHeartRate", onEvent);
|
|
942
|
+
}
|
|
943
|
+
return () => {
|
|
944
|
+
if (!set.delete(callback))
|
|
945
|
+
return;
|
|
946
|
+
if (heartRateWatch.readings.size + heartRateWatch.devices.size + heartRateWatch.states.size > 0)
|
|
947
|
+
return;
|
|
948
|
+
heartRateWatch.stop?.();
|
|
949
|
+
heartRateWatch.stop = null;
|
|
950
|
+
};
|
|
951
|
+
}
|
|
952
|
+
async function heartRateCall(method, fallback, normalize, ...args) {
|
|
953
|
+
const native = nativeHeartRate();
|
|
954
|
+
if (typeof native?.[method] !== "function")
|
|
955
|
+
return fallback();
|
|
956
|
+
return normalize(await native[method](...args));
|
|
957
|
+
}
|
|
958
|
+
var noRecording = () => normalizeHeartRateRecording({});
|
|
959
|
+
var heartRate = {
|
|
960
|
+
isAvailable() {
|
|
961
|
+
return typeof nativeHeartRate()?.getState === "function";
|
|
962
|
+
},
|
|
963
|
+
supports(method) {
|
|
964
|
+
return typeof nativeHeartRate()?.[method] === "function";
|
|
965
|
+
},
|
|
966
|
+
getState: () => heartRateCall("getState", noHeartRate, normalizeHeartRateState),
|
|
967
|
+
requestAuthorization: () => heartRateCall("requestAuthorization", () => "unavailable", (value) => HR_AUTHORIZATIONS.includes(value) ? value : "unavailable"),
|
|
968
|
+
scan: (options = {}) => heartRateCall("scan", noHeartRate, normalizeHeartRateState, options),
|
|
969
|
+
stopScan: () => heartRateCall("stopScan", noHeartRate, normalizeHeartRateState),
|
|
970
|
+
getDevices: () => heartRateCall("getDevices", () => [], (value) => Array.isArray(value) ? value.map(normalizeHeartRateDevice).filter((device2) => device2 !== null) : []),
|
|
971
|
+
connect: (id) => heartRateCall("connect", noHeartRate, normalizeHeartRateState, id),
|
|
972
|
+
reconnect: () => heartRateCall("reconnect", noHeartRate, normalizeHeartRateState),
|
|
973
|
+
disconnect: (options = {}) => heartRateCall("disconnect", noHeartRate, normalizeHeartRateState, options),
|
|
974
|
+
startRecording: () => heartRateCall("startRecording", noRecording, normalizeHeartRateRecording),
|
|
975
|
+
pauseRecording: () => heartRateCall("pauseRecording", noRecording, normalizeHeartRateRecording),
|
|
976
|
+
resumeRecording: () => heartRateCall("resumeRecording", noRecording, normalizeHeartRateRecording),
|
|
977
|
+
stopRecording: () => heartRateCall("stopRecording", () => ({ ...noRecording(), samples: [] }), (value) => ({ ...normalizeHeartRateRecording(value), samples: normalizeHeartRateSamples(hrRecord(value).samples) })),
|
|
978
|
+
getRecordingState: () => heartRateCall("getRecordingState", noRecording, normalizeHeartRateRecording),
|
|
979
|
+
readRecording: (options = {}) => heartRateCall("readRecording", () => [], normalizeHeartRateSamples, options),
|
|
980
|
+
onReading: (callback) => hrSubscribe(heartRateWatch.readings, callback),
|
|
981
|
+
onDevice: (callback) => hrSubscribe(heartRateWatch.devices, callback),
|
|
982
|
+
onState: (callback) => hrSubscribe(heartRateWatch.states, callback)
|
|
983
|
+
};
|
|
826
984
|
var deepLinks = {
|
|
827
985
|
async getInitialURL() {
|
|
828
986
|
return normalizeDeepLinkURL(await getCraftRoot()?.deepLinks?.getInitialURL?.());
|
|
@@ -1314,6 +1472,10 @@ export {
|
|
|
1314
1472
|
permissions,
|
|
1315
1473
|
notifications,
|
|
1316
1474
|
normalizeNowPlaying,
|
|
1475
|
+
normalizeHeartRateState,
|
|
1476
|
+
normalizeHeartRateSamples,
|
|
1477
|
+
normalizeHeartRateReading,
|
|
1478
|
+
normalizeHeartRateDevice,
|
|
1317
1479
|
normalizeDeepLinkURL,
|
|
1318
1480
|
network,
|
|
1319
1481
|
music,
|
|
@@ -1323,6 +1485,7 @@ export {
|
|
|
1323
1485
|
keepAwake,
|
|
1324
1486
|
isSnapshotName,
|
|
1325
1487
|
hybrid,
|
|
1488
|
+
heartRate,
|
|
1326
1489
|
health,
|
|
1327
1490
|
haptics,
|
|
1328
1491
|
dialog,
|