@bota.dev/react-native-app-sdk 2.0.0-beta.0

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.
Files changed (173) hide show
  1. package/BotaDeviceSDK.podspec +55 -0
  2. package/LICENSE +21 -0
  3. package/README.md +101 -0
  4. package/android/build.gradle +76 -0
  5. package/android/src/main/AndroidManifest.xml +1 -0
  6. package/android/src/main/java/dev/bota/sdk/reactnative/BotaDeviceSDKAndroidDevices.kt +172 -0
  7. package/android/src/main/java/dev/bota/sdk/reactnative/BotaDeviceSDKAndroidLifecycle.kt +100 -0
  8. package/android/src/main/java/dev/bota/sdk/reactnative/BotaDeviceSDKAndroidLogs.kt +82 -0
  9. package/android/src/main/java/dev/bota/sdk/reactnative/BotaDeviceSDKAndroidModels.kt +476 -0
  10. package/android/src/main/java/dev/bota/sdk/reactnative/BotaDeviceSDKAndroidOTA.kt +58 -0
  11. package/android/src/main/java/dev/bota/sdk/reactnative/BotaDeviceSDKAndroidRecordingUploads.kt +180 -0
  12. package/android/src/main/java/dev/bota/sdk/reactnative/BotaDeviceSDKAndroidRecordings.kt +751 -0
  13. package/android/src/main/java/dev/bota/sdk/reactnative/BotaDeviceSDKAndroidSecurity.kt +509 -0
  14. package/android/src/main/java/dev/bota/sdk/reactnative/BotaDeviceSDKAndroidWiFi.kt +125 -0
  15. package/android/src/main/java/dev/bota/sdk/reactnative/BotaDeviceSDKEncryptedUploadV2Materials.kt +46 -0
  16. package/android/src/main/java/dev/bota/sdk/reactnative/BotaDeviceSDKModule.kt +723 -0
  17. package/android/src/main/java/dev/bota/sdk/reactnative/BotaDeviceSDKPackage.kt +33 -0
  18. package/android/src/test/java/dev/bota/sdk/reactnative/BotaDeviceSDKAndroidDevicesTest.kt +211 -0
  19. package/android/src/test/java/dev/bota/sdk/reactnative/BotaDeviceSDKAndroidLifecycleTest.kt +132 -0
  20. package/android/src/test/java/dev/bota/sdk/reactnative/BotaDeviceSDKAndroidLogsTest.kt +57 -0
  21. package/android/src/test/java/dev/bota/sdk/reactnative/BotaDeviceSDKAndroidOTATest.kt +75 -0
  22. package/android/src/test/java/dev/bota/sdk/reactnative/BotaDeviceSDKAndroidRecordingUploadsTest.kt +147 -0
  23. package/android/src/test/java/dev/bota/sdk/reactnative/BotaDeviceSDKAndroidRecordingsTest.kt +355 -0
  24. package/android/src/test/java/dev/bota/sdk/reactnative/BotaDeviceSDKAndroidSecurityTest.kt +523 -0
  25. package/android/src/test/java/dev/bota/sdk/reactnative/BotaDeviceSDKAndroidWiFiTest.kt +100 -0
  26. package/generated/codegen-contract.json +3593 -0
  27. package/ios/BotaDeviceSDK.mm +1300 -0
  28. package/ios/BotaDeviceSDKAppleBridge.swift +2260 -0
  29. package/ios/BotaDeviceSDKAppleDevices.swift +191 -0
  30. package/ios/BotaDeviceSDKAppleLifecycle.swift +167 -0
  31. package/ios/BotaDeviceSDKAppleLogs.swift +79 -0
  32. package/ios/BotaDeviceSDKAppleOTA.swift +73 -0
  33. package/ios/BotaDeviceSDKAppleRecordingUploads.swift +178 -0
  34. package/ios/BotaDeviceSDKAppleRecordings.swift +670 -0
  35. package/ios/BotaDeviceSDKAppleSecurity.swift +586 -0
  36. package/ios/BotaDeviceSDKAppleWiFi.swift +128 -0
  37. package/ios/BotaDeviceSDKEncryptedUploadV2Materials.swift +99 -0
  38. package/lib/commonjs/BotaClient.js +194 -0
  39. package/lib/commonjs/BotaClient.js.map +1 -0
  40. package/lib/commonjs/ble/constants.js +10 -0
  41. package/lib/commonjs/ble/constants.js.map +1 -0
  42. package/lib/commonjs/ble/deviceLogs.js +50 -0
  43. package/lib/commonjs/ble/deviceLogs.js.map +1 -0
  44. package/lib/commonjs/cache/DeviceStateCache.js +6 -0
  45. package/lib/commonjs/cache/DeviceStateCache.js.map +1 -0
  46. package/lib/commonjs/client.js +746 -0
  47. package/lib/commonjs/client.js.map +1 -0
  48. package/lib/commonjs/compatibility/runtime.js +44 -0
  49. package/lib/commonjs/compatibility/runtime.js.map +1 -0
  50. package/lib/commonjs/index.js +123 -0
  51. package/lib/commonjs/index.js.map +1 -0
  52. package/lib/commonjs/managers/DeviceManager.js +647 -0
  53. package/lib/commonjs/managers/DeviceManager.js.map +1 -0
  54. package/lib/commonjs/managers/OTAManager.js +140 -0
  55. package/lib/commonjs/managers/OTAManager.js.map +1 -0
  56. package/lib/commonjs/managers/RecordingManager.js +444 -0
  57. package/lib/commonjs/managers/RecordingManager.js.map +1 -0
  58. package/lib/commonjs/managers/StreamingSession.js +175 -0
  59. package/lib/commonjs/managers/StreamingSession.js.map +1 -0
  60. package/lib/commonjs/managers/types.js +6 -0
  61. package/lib/commonjs/managers/types.js.map +1 -0
  62. package/lib/commonjs/models/Device.js +2 -0
  63. package/lib/commonjs/models/Device.js.map +1 -0
  64. package/lib/commonjs/models/Recording.js +2 -0
  65. package/lib/commonjs/models/Recording.js.map +1 -0
  66. package/lib/commonjs/models/Status.js +6 -0
  67. package/lib/commonjs/models/Status.js.map +1 -0
  68. package/lib/commonjs/package.json +1 -0
  69. package/lib/commonjs/specs/NativeBotaDeviceSDK.js +9 -0
  70. package/lib/commonjs/specs/NativeBotaDeviceSDK.js.map +1 -0
  71. package/lib/commonjs/sync/syncStatus.js +178 -0
  72. package/lib/commonjs/sync/syncStatus.js.map +1 -0
  73. package/lib/commonjs/utils/errors.js +194 -0
  74. package/lib/commonjs/utils/errors.js.map +1 -0
  75. package/lib/commonjs/utils/logger.js +84 -0
  76. package/lib/commonjs/utils/logger.js.map +1 -0
  77. package/lib/module/BotaClient.js +189 -0
  78. package/lib/module/BotaClient.js.map +1 -0
  79. package/lib/module/ble/constants.js +6 -0
  80. package/lib/module/ble/constants.js.map +1 -0
  81. package/lib/module/ble/deviceLogs.js +45 -0
  82. package/lib/module/ble/deviceLogs.js.map +1 -0
  83. package/lib/module/cache/DeviceStateCache.js +4 -0
  84. package/lib/module/cache/DeviceStateCache.js.map +1 -0
  85. package/lib/module/client.js +740 -0
  86. package/lib/module/client.js.map +1 -0
  87. package/lib/module/compatibility/runtime.js +35 -0
  88. package/lib/module/compatibility/runtime.js.map +1 -0
  89. package/lib/module/index.js +17 -0
  90. package/lib/module/index.js.map +1 -0
  91. package/lib/module/managers/DeviceManager.js +641 -0
  92. package/lib/module/managers/DeviceManager.js.map +1 -0
  93. package/lib/module/managers/OTAManager.js +134 -0
  94. package/lib/module/managers/OTAManager.js.map +1 -0
  95. package/lib/module/managers/RecordingManager.js +438 -0
  96. package/lib/module/managers/RecordingManager.js.map +1 -0
  97. package/lib/module/managers/StreamingSession.js +168 -0
  98. package/lib/module/managers/StreamingSession.js.map +1 -0
  99. package/lib/module/managers/types.js +4 -0
  100. package/lib/module/managers/types.js.map +1 -0
  101. package/lib/module/models/Device.js +2 -0
  102. package/lib/module/models/Device.js.map +1 -0
  103. package/lib/module/models/Recording.js +2 -0
  104. package/lib/module/models/Recording.js.map +1 -0
  105. package/lib/module/models/Status.js +4 -0
  106. package/lib/module/models/Status.js.map +1 -0
  107. package/lib/module/specs/NativeBotaDeviceSDK.js +5 -0
  108. package/lib/module/specs/NativeBotaDeviceSDK.js.map +1 -0
  109. package/lib/module/sync/syncStatus.js +174 -0
  110. package/lib/module/sync/syncStatus.js.map +1 -0
  111. package/lib/module/utils/errors.js +183 -0
  112. package/lib/module/utils/errors.js.map +1 -0
  113. package/lib/module/utils/logger.js +80 -0
  114. package/lib/module/utils/logger.js.map +1 -0
  115. package/lib/typescript/BotaClient.d.ts +40 -0
  116. package/lib/typescript/BotaClient.d.ts.map +1 -0
  117. package/lib/typescript/ble/constants.d.ts +4 -0
  118. package/lib/typescript/ble/constants.d.ts.map +1 -0
  119. package/lib/typescript/ble/deviceLogs.d.ts +10 -0
  120. package/lib/typescript/ble/deviceLogs.d.ts.map +1 -0
  121. package/lib/typescript/cache/DeviceStateCache.d.ts +14 -0
  122. package/lib/typescript/cache/DeviceStateCache.d.ts.map +1 -0
  123. package/lib/typescript/client.d.ts +208 -0
  124. package/lib/typescript/client.d.ts.map +1 -0
  125. package/lib/typescript/compatibility/runtime.d.ts +11 -0
  126. package/lib/typescript/compatibility/runtime.d.ts.map +1 -0
  127. package/lib/typescript/index.d.ts +19 -0
  128. package/lib/typescript/index.d.ts.map +1 -0
  129. package/lib/typescript/managers/DeviceManager.d.ts +117 -0
  130. package/lib/typescript/managers/DeviceManager.d.ts.map +1 -0
  131. package/lib/typescript/managers/OTAManager.d.ts +25 -0
  132. package/lib/typescript/managers/OTAManager.d.ts.map +1 -0
  133. package/lib/typescript/managers/RecordingManager.d.ts +54 -0
  134. package/lib/typescript/managers/RecordingManager.d.ts.map +1 -0
  135. package/lib/typescript/managers/StreamingSession.d.ts +35 -0
  136. package/lib/typescript/managers/StreamingSession.d.ts.map +1 -0
  137. package/lib/typescript/managers/types.d.ts +19 -0
  138. package/lib/typescript/managers/types.d.ts.map +1 -0
  139. package/lib/typescript/models/Device.d.ts +392 -0
  140. package/lib/typescript/models/Device.d.ts.map +1 -0
  141. package/lib/typescript/models/Recording.d.ts +259 -0
  142. package/lib/typescript/models/Recording.d.ts.map +1 -0
  143. package/lib/typescript/models/Status.d.ts +106 -0
  144. package/lib/typescript/models/Status.d.ts.map +1 -0
  145. package/lib/typescript/specs/NativeBotaDeviceSDK.d.ts +393 -0
  146. package/lib/typescript/specs/NativeBotaDeviceSDK.d.ts.map +1 -0
  147. package/lib/typescript/sync/syncStatus.d.ts +123 -0
  148. package/lib/typescript/sync/syncStatus.d.ts.map +1 -0
  149. package/lib/typescript/utils/errors.d.ts +83 -0
  150. package/lib/typescript/utils/errors.d.ts.map +1 -0
  151. package/lib/typescript/utils/logger.d.ts +36 -0
  152. package/lib/typescript/utils/logger.d.ts.map +1 -0
  153. package/package.json +100 -0
  154. package/scripts/bota_device_sdk_spm_workaround.rb +53 -0
  155. package/src/BotaClient.ts +231 -0
  156. package/src/ble/constants.ts +3 -0
  157. package/src/ble/deviceLogs.ts +61 -0
  158. package/src/cache/DeviceStateCache.ts +20 -0
  159. package/src/client.ts +1410 -0
  160. package/src/compatibility/runtime.ts +55 -0
  161. package/src/index.ts +146 -0
  162. package/src/managers/DeviceManager.ts +927 -0
  163. package/src/managers/OTAManager.ts +195 -0
  164. package/src/managers/RecordingManager.ts +578 -0
  165. package/src/managers/StreamingSession.ts +212 -0
  166. package/src/managers/types.ts +36 -0
  167. package/src/models/Device.ts +484 -0
  168. package/src/models/Recording.ts +281 -0
  169. package/src/models/Status.ts +128 -0
  170. package/src/specs/NativeBotaDeviceSDK.ts +542 -0
  171. package/src/sync/syncStatus.ts +243 -0
  172. package/src/utils/errors.ts +327 -0
  173. package/src/utils/logger.ts +122 -0
@@ -0,0 +1,927 @@
1
+ import EventEmitter from 'eventemitter3';
2
+
3
+ import type {
4
+ BotaAsyncEventSubscription,
5
+ BotaDeviceSDKClient,
6
+ BotaEventSubscription,
7
+ } from '../client';
8
+ import {
9
+ getCompatibilityClient,
10
+ subscribeToCompatibilityDisconnections,
11
+ } from '../compatibility/runtime';
12
+ import type {
13
+ CachedDeviceState,
14
+ } from '../cache/DeviceStateCache';
15
+ import type {
16
+ ConnectedDevice,
17
+ DeviceConnectionSettings,
18
+ DeviceLogEvent,
19
+ DeviceStatus,
20
+ DeviceWiFiScanResult,
21
+ DiscoveredDevice,
22
+ ReconnectOptions,
23
+ ScanOptions,
24
+ WiFiConfigGrant,
25
+ WiFiConfigResult,
26
+ WiFiCredentials,
27
+ WiFiStatusInfo,
28
+ Environment,
29
+ BleFactoryResetResult,
30
+ BleFactoryResetResultPersister,
31
+ RecordingState,
32
+ StartRecordingOptions,
33
+ StopRecordingOptions,
34
+ } from '../models/Device';
35
+ import type { DeviceManagerEvents } from '../models/Status';
36
+ import { DeviceError, ProvisioningError } from '../utils/errors';
37
+
38
+ type Subscription = { remove(): void };
39
+
40
+ export type RecordingGrantFetcher = (
41
+ nonce: string | null
42
+ ) => Promise<string>;
43
+
44
+ export type RadioPriority = 'user' | 'background';
45
+
46
+ export interface ProvisionOptions {
47
+ fetchDeprovisionGrant?: RecordingGrantFetcher;
48
+ }
49
+
50
+ type CacheListener = (
51
+ serialNumber: string,
52
+ patch: { wifiStatus?: Partial<WiFiStatusInfo> | null },
53
+ state: CachedDeviceState
54
+ ) => void;
55
+
56
+ export class DeviceManager extends EventEmitter<DeviceManagerEvents> {
57
+ private readonly client: BotaDeviceSDKClient;
58
+ private readonly discoveredDevices = new Map<string, DiscoveredDevice>();
59
+ private readonly connectedDevices = new Map<string, ConnectedDevice>();
60
+ private readonly knownBleIds = new Map<string, string>();
61
+ private readonly cache = new Map<string, CachedDeviceState>();
62
+ private readonly cacheListeners = new Set<CacheListener>();
63
+ private readonly statusSubscriptions = new Map<
64
+ string,
65
+ Promise<BotaAsyncEventSubscription>
66
+ >();
67
+ private readonly statusCallbacks = new Map<
68
+ string,
69
+ Set<(status: DeviceStatus) => void>
70
+ >();
71
+ private readonly wifiStatusSubscriptions = new Map<
72
+ string,
73
+ Promise<BotaAsyncEventSubscription>
74
+ >();
75
+ private readonly logSubscriptions = new Map<
76
+ string,
77
+ Promise<BotaAsyncEventSubscription>
78
+ >();
79
+ private readonly recordingStateCache = new Map<string, RecordingState>();
80
+ private readonly recordingStatePending = new Map<
81
+ string,
82
+ Promise<RecordingState>
83
+ >();
84
+ private readonly recordingStateSubscriptions = new Map<
85
+ string,
86
+ Promise<BotaAsyncEventSubscription>
87
+ >();
88
+ private readonly reconnectInFlight = new Map<
89
+ string,
90
+ Promise<ConnectedDevice>
91
+ >();
92
+ private reconnectChain: Promise<unknown> = Promise.resolve();
93
+ private scanSubscription: BotaEventSubscription | null = null;
94
+ private scanActive = false;
95
+ private autoReconnectEnabled = false;
96
+ private autoReconnectSerial: string | null = null;
97
+ private autoReconnectTimer: ReturnType<typeof setInterval> | null = null;
98
+ private autoReconnectAttempting = false;
99
+ private userDisconnected = false;
100
+ private destroyed = false;
101
+ private readonly disconnectionSubscription: Subscription;
102
+
103
+ constructor() {
104
+ super();
105
+ this.client = getCompatibilityClient();
106
+ this.disconnectionSubscription = subscribeToCompatibilityDisconnections(
107
+ this.client,
108
+ (error) => this.handleNativeDisconnection(error)
109
+ );
110
+ }
111
+
112
+ async initialize(): Promise<void> {}
113
+
114
+ async startScan(options: ScanOptions = {}): Promise<void> {
115
+ this.assertAlive();
116
+ if (this.scanActive) return;
117
+ this.emit('scanStarted');
118
+ try {
119
+ this.scanSubscription = await this.client.devices.startScan(
120
+ options,
121
+ (device) => {
122
+ this.discoveredDevices.set(device.id, device);
123
+ this.emit('deviceDiscovered', device);
124
+ }
125
+ );
126
+ this.scanActive = true;
127
+ } catch (error) {
128
+ this.emit('scanError', asError(error));
129
+ throw error;
130
+ }
131
+ }
132
+
133
+ stopScan(): void {
134
+ if (!this.scanActive) return;
135
+ this.scanActive = false;
136
+ this.scanSubscription?.remove();
137
+ this.scanSubscription = null;
138
+ void this.client.devices.stopScan().catch((error) => {
139
+ this.emit('scanError', asError(error));
140
+ });
141
+ this.emit('scanStopped');
142
+ }
143
+
144
+ getDiscoveredDevices(): DiscoveredDevice[] {
145
+ return Array.from(this.discoveredDevices.values());
146
+ }
147
+
148
+ getConnectedDevices(): ConnectedDevice[] {
149
+ return Array.from(this.connectedDevices.values());
150
+ }
151
+
152
+ getKnownBleIds(): Record<string, string> {
153
+ return Object.fromEntries(this.knownBleIds);
154
+ }
155
+
156
+ async connect(
157
+ device: DiscoveredDevice,
158
+ priority: RadioPriority = 'user'
159
+ ): Promise<ConnectedDevice> {
160
+ void priority;
161
+ this.assertAlive();
162
+ this.userDisconnected = false;
163
+ this.emit('connectionStateChanged', device.id, 'connecting');
164
+ try {
165
+ const connected = await this.client.devices.connect(device);
166
+ this.rememberConnected(connected);
167
+ if (this.autoReconnectEnabled) this.ensureStatusWatchdog(connected);
168
+ this.emit('connectionStateChanged', connected.id, 'connected');
169
+ this.emit('deviceConnected', connected);
170
+ return connected;
171
+ } catch (error) {
172
+ this.emit('connectionStateChanged', device.id, 'disconnected');
173
+ throw error;
174
+ }
175
+ }
176
+
177
+ async reconnect(
178
+ serialNumber: string,
179
+ options?: ReconnectOptions
180
+ ): Promise<ConnectedDevice> {
181
+ this.assertAlive();
182
+ const existing = this.findConnectedBySerial(serialNumber);
183
+ if (existing) return existing;
184
+ this.userDisconnected = false;
185
+ const inFlight = this.reconnectInFlight.get(serialNumber);
186
+ if (inFlight) return inFlight;
187
+ const run = this.reconnectChain
188
+ .catch(() => undefined)
189
+ .then(async () => {
190
+ const connected = await this.client.devices.reconnect(
191
+ serialNumber,
192
+ options
193
+ );
194
+ this.rememberConnected(connected);
195
+ if (this.autoReconnectEnabled) this.ensureStatusWatchdog(connected);
196
+ this.emit('connectionStateChanged', connected.id, 'connected');
197
+ this.emit('deviceConnected', connected);
198
+ return connected;
199
+ });
200
+ this.reconnectChain = run.catch(() => undefined);
201
+ this.reconnectInFlight.set(serialNumber, run);
202
+ void run.finally(() => {
203
+ if (this.reconnectInFlight.get(serialNumber) === run) {
204
+ this.reconnectInFlight.delete(serialNumber);
205
+ }
206
+ }).catch(() => undefined);
207
+ return run;
208
+ }
209
+
210
+ async disconnect(device: ConnectedDevice): Promise<void> {
211
+ this.assertAlive();
212
+ this.userDisconnected = true;
213
+ this.stopAutoReconnectLoop();
214
+ this.emit('connectionStateChanged', device.id, 'disconnecting');
215
+ await this.removeOwned(this.statusSubscriptions, device.id);
216
+ this.statusCallbacks.delete(device.id);
217
+ await this.removeOwned(this.wifiStatusSubscriptions, device.id);
218
+ await this.removeOwned(this.logSubscriptions, device.id);
219
+ await this.removeOwned(this.recordingStateSubscriptions, device.id);
220
+ await this.client.devices.disconnect();
221
+ this.connectedDevices.delete(device.id);
222
+ this.recordingStateCache.delete(device.id);
223
+ this.recordingStatePending.delete(device.id);
224
+ this.emit('connectionStateChanged', device.id, 'disconnected');
225
+ this.emit('deviceDisconnected', device.id);
226
+ }
227
+
228
+ isConnected(deviceId: string): boolean {
229
+ return this.connectedDevices.get(deviceId)?.connectionState === 'connected';
230
+ }
231
+
232
+ async getStatus(device: ConnectedDevice): Promise<DeviceStatus> {
233
+ this.requireConnected(device.id);
234
+ return this.client.devices.readStatus();
235
+ }
236
+
237
+ async isProvisioned(device: ConnectedDevice): Promise<boolean> {
238
+ this.requireConnected(device.id);
239
+ return this.client.controls.isProvisioned(device);
240
+ }
241
+
242
+ async readPublicKey(device: ConnectedDevice): Promise<string | null> {
243
+ this.requireConnected(device.id);
244
+ return this.client.controls.readPublicKey(device);
245
+ }
246
+
247
+ async readAuthNonce(device: ConnectedDevice): Promise<string | null> {
248
+ this.requireConnected(device.id);
249
+ return this.client.controls.readAuthNonce(device);
250
+ }
251
+
252
+ async setApiEndpoint(
253
+ device: ConnectedDevice,
254
+ environment: Environment
255
+ ): Promise<void> {
256
+ this.requireConnected(device.id);
257
+ await this.client.controls.setApiEndpoint(device, environment);
258
+ }
259
+
260
+ async deliverCert(
261
+ device: ConnectedDevice,
262
+ certPem: string,
263
+ privkeyPem: string
264
+ ): Promise<void> {
265
+ this.requireConnected(device.id);
266
+ await this.client.controls.deliverCertificate(
267
+ device,
268
+ certPem,
269
+ privkeyPem
270
+ );
271
+ }
272
+
273
+ async deliverBackendPubkey(
274
+ device: ConnectedDevice,
275
+ pubkey: Uint8Array
276
+ ): Promise<void> {
277
+ this.requireConnected(device.id);
278
+ await this.client.controls.deliverBackendPublicKey(device, pubkey);
279
+ }
280
+
281
+ async writeGrant(device: ConnectedDevice, grantBlob: string): Promise<void> {
282
+ this.requireConnected(device.id);
283
+ await this.client.controls.writeGrant(device, grantBlob);
284
+ }
285
+
286
+ async syncTime(deviceId: string): Promise<void> {
287
+ await this.client.controls.syncTime(this.requireConnected(deviceId));
288
+ }
289
+
290
+ async provision(
291
+ device: ConnectedDevice,
292
+ deviceToken: string,
293
+ environment: Environment = 'production',
294
+ options?: ProvisionOptions
295
+ ): Promise<void> {
296
+ this.requireConnected(device.id);
297
+ const provision = () => this.client.provisioning.provision(
298
+ device,
299
+ async () => ({
300
+ apiEndpoint: String.fromCharCode(environmentCode(environment)),
301
+ deviceToken,
302
+ mtu: device.mtu,
303
+ })
304
+ );
305
+ try {
306
+ await provision();
307
+ } catch (error) {
308
+ if (!isAlreadyPaired(error) || !options?.fetchDeprovisionGrant) {
309
+ throw provisioningError(error, device.id);
310
+ }
311
+ const nonce = await this.readAuthNonce(device).catch(() => null);
312
+ const grantBlob = await options.fetchDeprovisionGrant(nonce);
313
+ const result = await this.deprovision(device, grantBlob);
314
+ if (!result.success) {
315
+ throw new ProvisioningError(
316
+ `Auto-deprovision failed: ${result.error ?? 'unknown'}`,
317
+ 'PROVISIONING_FAILED',
318
+ device.id
319
+ );
320
+ }
321
+ await provision();
322
+ }
323
+ }
324
+
325
+ async deprovision(
326
+ device: ConnectedDevice,
327
+ grantBlob: string
328
+ ): Promise<{ success: boolean; error?: string }> {
329
+ this.requireConnected(device.id);
330
+ return this.client.provisioning.deprovision(device, grantBlob);
331
+ }
332
+
333
+ async factoryReset(device: ConnectedDevice): Promise<void> {
334
+ this.requireConnected(device.id);
335
+ throw ProvisioningError.invalidToken(device.id);
336
+ }
337
+
338
+ async bleFactoryReset(
339
+ device: ConnectedDevice,
340
+ grantBlob: string,
341
+ persistResult: BleFactoryResetResultPersister
342
+ ): Promise<BleFactoryResetResult> {
343
+ this.requireConnected(device.id);
344
+ let persisted: Extract<BleFactoryResetResult, { success: true }> | undefined;
345
+ try {
346
+ await this.client.factoryReset.factoryReset(
347
+ device,
348
+ {
349
+ commandId: nextCompatibilityResetCommandId(),
350
+ bindingGeneration: 0,
351
+ },
352
+ async () => grantBlob,
353
+ async (result) => {
354
+ await persistResult(result);
355
+ persisted = result;
356
+ }
357
+ );
358
+ } catch (error) {
359
+ const rejected = factoryResetRejection(error);
360
+ if (rejected) return rejected;
361
+ throw error;
362
+ }
363
+ if (!persisted) {
364
+ throw new Error('Factory reset completed without a persisted result');
365
+ }
366
+ return persisted;
367
+ }
368
+
369
+ async resumeBleFactoryReset(
370
+ device: ConnectedDevice,
371
+ persistResult: BleFactoryResetResultPersister
372
+ ): Promise<BleFactoryResetResult> {
373
+ this.requireConnected(device.id);
374
+ let persisted: Extract<BleFactoryResetResult, { success: true }> | undefined;
375
+ try {
376
+ await this.client.factoryReset.resumePendingFactoryReset(
377
+ device,
378
+ 0,
379
+ async (result) => {
380
+ await persistResult(result);
381
+ persisted = result;
382
+ }
383
+ );
384
+ } catch (error) {
385
+ const rejected = factoryResetRejection(error);
386
+ if (rejected) return rejected;
387
+ throw error;
388
+ }
389
+ if (!persisted) {
390
+ throw new Error('Factory reset resume completed without a persisted result');
391
+ }
392
+ return persisted;
393
+ }
394
+
395
+ async requestStartRecording(
396
+ device: ConnectedDevice,
397
+ grantOrFetcher: string | RecordingGrantFetcher,
398
+ _options?: StartRecordingOptions
399
+ ): Promise<{ success: boolean; error?: string }> {
400
+ this.requireConnected(device.id);
401
+ if (typeof grantOrFetcher === 'string') {
402
+ return this.runRecordingControl(device, grantOrFetcher, 'start');
403
+ }
404
+ const grantBlob = await this.fetchRecordingGrant(device, grantOrFetcher);
405
+ return this.runRecordingControl(device, grantBlob, 'start');
406
+ }
407
+
408
+ async requestStopRecording(
409
+ device: ConnectedDevice,
410
+ grantOrFetcher: string | RecordingGrantFetcher,
411
+ _options?: StopRecordingOptions
412
+ ): Promise<{ success: boolean; error?: string }> {
413
+ this.requireConnected(device.id);
414
+ if (typeof grantOrFetcher === 'string') {
415
+ return this.runRecordingControl(device, grantOrFetcher, 'stop');
416
+ }
417
+ const grantBlob = await this.fetchRecordingGrant(device, grantOrFetcher);
418
+ return this.runRecordingControl(device, grantBlob, 'stop');
419
+ }
420
+
421
+ async getRecordingState(device: ConnectedDevice): Promise<RecordingState> {
422
+ this.requireConnected(device.id);
423
+ const pending = this.recordingStatePending.get(device.id);
424
+ if (pending) return pending;
425
+ try {
426
+ return await this.readAndCacheRecordingState(device);
427
+ } catch {
428
+ return this.cachedRecordingState(device.id);
429
+ }
430
+ }
431
+
432
+ subscribeToRecordingState(
433
+ device: ConnectedDevice,
434
+ callback: (state: RecordingState) => void
435
+ ): () => void {
436
+ this.requireConnected(device.id);
437
+ const promise = this.replaceOwned(
438
+ this.recordingStateSubscriptions,
439
+ device.id,
440
+ this.client.controls.subscribeToRecordingState(device, callback)
441
+ );
442
+ return idempotentRemoval(() => {
443
+ void this.removeExpected(
444
+ this.recordingStateSubscriptions,
445
+ device.id,
446
+ promise
447
+ );
448
+ });
449
+ }
450
+
451
+ subscribeToStatus(
452
+ device: ConnectedDevice,
453
+ callback: (status: DeviceStatus) => void
454
+ ): () => void {
455
+ this.requireConnected(device.id);
456
+ const callbacks = this.statusCallbacks.get(device.id) ?? new Set();
457
+ callbacks.add(callback);
458
+ this.statusCallbacks.set(device.id, callbacks);
459
+ this.ensureStatusWatchdog(device);
460
+ return idempotentRemoval(() => {
461
+ callbacks.delete(callback);
462
+ if (callbacks.size === 0) {
463
+ this.statusCallbacks.delete(device.id);
464
+ if (!this.autoReconnectEnabled) {
465
+ void this.removeOwned(this.statusSubscriptions, device.id)
466
+ .catch(() => undefined);
467
+ }
468
+ }
469
+ });
470
+ }
471
+
472
+ async subscribeToDeviceLogs(
473
+ device: ConnectedDevice,
474
+ callback: (event: DeviceLogEvent) => void
475
+ ): Promise<() => void> {
476
+ this.requireConnected(device.id);
477
+ const promise = this.replaceOwned(
478
+ this.logSubscriptions,
479
+ device.id,
480
+ this.client.logs.subscribe(device, callback)
481
+ );
482
+ await promise;
483
+ return idempotentRemoval(() => {
484
+ void this.removeExpected(this.logSubscriptions, device.id, promise);
485
+ });
486
+ }
487
+
488
+ async readConnectionSettings(
489
+ device: ConnectedDevice
490
+ ): Promise<DeviceConnectionSettings> {
491
+ this.requireConnected(device.id);
492
+ return this.client.provisioning.readConnectionSettings(device);
493
+ }
494
+
495
+ async writeConnectionSettings(
496
+ device: ConnectedDevice,
497
+ settings: DeviceConnectionSettings
498
+ ): Promise<void> {
499
+ this.requireConnected(device.id);
500
+ await this.client.provisioning.writeConnectionSettings(device, settings);
501
+ }
502
+
503
+ async configureWiFi(
504
+ deviceId: string,
505
+ credentials: WiFiCredentials,
506
+ grant: WiFiConfigGrant
507
+ ): Promise<WiFiConfigResult> {
508
+ return this.client.wifi.configure(
509
+ this.requireConnected(deviceId),
510
+ credentials,
511
+ grant
512
+ );
513
+ }
514
+
515
+ async disconnectWiFi(deviceId: string): Promise<WiFiConfigResult> {
516
+ return this.client.wifi.disconnect(this.requireConnected(deviceId));
517
+ }
518
+
519
+ async getWiFiStatus(deviceId: string): Promise<WiFiStatusInfo> {
520
+ const device = this.requireConnected(deviceId);
521
+ const status = await this.client.wifi.readStatus(device);
522
+ this.updateCachedDeviceState(device.serialNumber, { wifiStatus: status });
523
+ return status;
524
+ }
525
+
526
+ subscribeToWiFiStatus(
527
+ deviceId: string,
528
+ callback: (status: WiFiStatusInfo) => void
529
+ ): Subscription {
530
+ const device = this.requireConnected(deviceId);
531
+ const promise = this.replaceOwned(
532
+ this.wifiStatusSubscriptions,
533
+ device.id,
534
+ this.client.wifi.subscribeToStatus(device, (status) => {
535
+ this.updateCachedDeviceState(device.serialNumber, { wifiStatus: status });
536
+ callback(status);
537
+ })
538
+ );
539
+ return {
540
+ remove: idempotentRemoval(() => {
541
+ void this.removeExpected(this.wifiStatusSubscriptions, device.id, promise);
542
+ }),
543
+ };
544
+ }
545
+
546
+ async scanWiFiNetworks(
547
+ device: ConnectedDevice
548
+ ): Promise<DeviceWiFiScanResult> {
549
+ this.requireConnected(device.id);
550
+ return this.client.wifi.scanNetworks(device);
551
+ }
552
+
553
+ getCachedDeviceState(serialNumber: string): CachedDeviceState | null {
554
+ return this.cache.get(serialNumber) ?? null;
555
+ }
556
+
557
+ getCachedWiFiStatus(serialNumber: string): WiFiStatusInfo | null {
558
+ return this.cache.get(serialNumber)?.wifiStatus ?? null;
559
+ }
560
+
561
+ updateCachedDeviceState(
562
+ serialNumber: string,
563
+ patch: { wifiStatus?: Partial<WiFiStatusInfo> | null }
564
+ ): void {
565
+ const previous = this.cache.get(serialNumber);
566
+ const wifiStatus = mergeWiFiStatus(previous?.wifiStatus, patch.wifiStatus);
567
+ const state: CachedDeviceState = {
568
+ ...(wifiStatus === undefined ? {} : { wifiStatus }),
569
+ updatedAt: Date.now(),
570
+ };
571
+ this.cache.set(serialNumber, state);
572
+ for (const listener of this.cacheListeners) listener(serialNumber, patch, state);
573
+ }
574
+
575
+ clearCachedDeviceState(serialNumber: string): void {
576
+ this.cache.delete(serialNumber);
577
+ }
578
+
579
+ clearAllCachedDeviceStates(): void {
580
+ this.cache.clear();
581
+ }
582
+
583
+ enableAutoReconnect(serialNumber: string): void {
584
+ this.assertAlive();
585
+ this.autoReconnectSerial = serialNumber;
586
+ this.autoReconnectEnabled = true;
587
+ this.userDisconnected = false;
588
+ const connected = this.findConnectedBySerial(serialNumber);
589
+ if (connected) {
590
+ this.ensureStatusWatchdog(connected);
591
+ } else {
592
+ this.startAutoReconnectLoop();
593
+ }
594
+ }
595
+
596
+ disableAutoReconnect(): void {
597
+ const serialNumber = this.autoReconnectSerial;
598
+ this.autoReconnectEnabled = false;
599
+ this.autoReconnectSerial = null;
600
+ this.userDisconnected = false;
601
+ this.stopAutoReconnectLoop();
602
+ const connected = serialNumber
603
+ ? this.findConnectedBySerial(serialNumber)
604
+ : null;
605
+ if (connected && !this.statusCallbacks.has(connected.id)) {
606
+ void this.removeOwned(this.statusSubscriptions, connected.id)
607
+ .catch(() => undefined);
608
+ }
609
+ }
610
+
611
+ onCachedDeviceStateChanged(
612
+ listener: (
613
+ serialNumber: string,
614
+ patch: { wifiStatus?: Partial<WiFiStatusInfo> | null },
615
+ state: CachedDeviceState
616
+ ) => void
617
+ ): { remove: () => void } {
618
+ this.cacheListeners.add(listener);
619
+ return { remove: () => this.cacheListeners.delete(listener) };
620
+ }
621
+
622
+ destroy(): void {
623
+ if (this.destroyed) return;
624
+ this.destroyed = true;
625
+ this.disconnectionSubscription.remove();
626
+ this.disableAutoReconnect();
627
+ this.stopScan();
628
+ for (const [deviceId] of this.statusSubscriptions) {
629
+ void this.removeOwned(this.statusSubscriptions, deviceId);
630
+ }
631
+ for (const [deviceId] of this.wifiStatusSubscriptions) {
632
+ void this.removeOwned(this.wifiStatusSubscriptions, deviceId);
633
+ }
634
+ for (const [deviceId] of this.logSubscriptions) {
635
+ void this.removeOwned(this.logSubscriptions, deviceId);
636
+ }
637
+ for (const [deviceId] of this.recordingStateSubscriptions) {
638
+ void this.removeOwned(this.recordingStateSubscriptions, deviceId);
639
+ }
640
+ this.connectedDevices.clear();
641
+ this.recordingStateCache.clear();
642
+ this.recordingStatePending.clear();
643
+ this.statusCallbacks.clear();
644
+ this.cache.clear();
645
+ this.cacheListeners.clear();
646
+ this.removeAllListeners();
647
+ }
648
+
649
+ private rememberConnected(device: ConnectedDevice): void {
650
+ this.connectedDevices.set(device.id, device);
651
+ this.knownBleIds.set(device.serialNumber, device.id);
652
+ }
653
+
654
+ private ensureStatusWatchdog(device: ConnectedDevice): void {
655
+ if (this.statusSubscriptions.has(device.id)) return;
656
+ const subscription = this.client.devices.subscribeToStatus((status) => {
657
+ for (const callback of this.statusCallbacks.get(device.id) ?? []) {
658
+ callback(status);
659
+ }
660
+ this.emit('deviceStatusUpdated', device.id, status);
661
+ });
662
+ this.statusSubscriptions.set(device.id, subscription);
663
+ void subscription.catch(() => {
664
+ if (this.statusSubscriptions.get(device.id) === subscription) {
665
+ this.statusSubscriptions.delete(device.id);
666
+ }
667
+ });
668
+ }
669
+
670
+ private handleNativeDisconnection(error?: Error): void {
671
+ if (this.destroyed || this.userDisconnected) return;
672
+ for (const device of this.connectedDevices.values()) {
673
+ this.connectedDevices.delete(device.id);
674
+ this.recordingStateCache.delete(device.id);
675
+ this.recordingStatePending.delete(device.id);
676
+ this.statusCallbacks.delete(device.id);
677
+ void this.removeOwned(this.statusSubscriptions, device.id)
678
+ .catch(() => undefined);
679
+ void this.removeOwned(this.wifiStatusSubscriptions, device.id)
680
+ .catch(() => undefined);
681
+ void this.removeOwned(this.logSubscriptions, device.id)
682
+ .catch(() => undefined);
683
+ void this.removeOwned(this.recordingStateSubscriptions, device.id)
684
+ .catch(() => undefined);
685
+ this.emit('connectionStateChanged', device.id, 'disconnected');
686
+ this.emit('deviceDisconnected', device.id, error);
687
+ if (
688
+ this.autoReconnectEnabled &&
689
+ this.autoReconnectSerial === device.serialNumber
690
+ ) {
691
+ this.startAutoReconnectLoop();
692
+ }
693
+ }
694
+ }
695
+
696
+ private startAutoReconnectLoop(): void {
697
+ if (this.autoReconnectTimer || !this.autoReconnectSerial) return;
698
+ const attempt = async (): Promise<void> => {
699
+ const serialNumber = this.autoReconnectSerial;
700
+ if (
701
+ !this.autoReconnectEnabled ||
702
+ !serialNumber ||
703
+ this.userDisconnected ||
704
+ this.destroyed
705
+ ) {
706
+ this.stopAutoReconnectLoop();
707
+ return;
708
+ }
709
+ if (this.findConnectedBySerial(serialNumber)) {
710
+ this.stopAutoReconnectLoop();
711
+ return;
712
+ }
713
+ if (this.autoReconnectAttempting) return;
714
+ this.autoReconnectAttempting = true;
715
+ try {
716
+ await this.reconnect(serialNumber, { scanTimeout: 5_000 });
717
+ this.stopAutoReconnectLoop();
718
+ } catch {
719
+ // The timer owns retries; callers still receive errors from reconnect().
720
+ } finally {
721
+ this.autoReconnectAttempting = false;
722
+ }
723
+ };
724
+ void attempt();
725
+ this.autoReconnectTimer = setInterval(() => void attempt(), 3_000);
726
+ }
727
+
728
+ private stopAutoReconnectLoop(): void {
729
+ if (!this.autoReconnectTimer) return;
730
+ clearInterval(this.autoReconnectTimer);
731
+ this.autoReconnectTimer = null;
732
+ }
733
+
734
+ private findConnectedBySerial(serialNumber: string): ConnectedDevice | null {
735
+ for (const device of this.connectedDevices.values()) {
736
+ if (
737
+ device.serialNumber === serialNumber &&
738
+ device.connectionState === 'connected'
739
+ ) {
740
+ return device;
741
+ }
742
+ }
743
+ return null;
744
+ }
745
+
746
+ private requireConnected(deviceId: string): ConnectedDevice {
747
+ const device = this.connectedDevices.get(deviceId);
748
+ if (!device || device.connectionState !== 'connected') {
749
+ throw DeviceError.notConnected(deviceId);
750
+ }
751
+ return device;
752
+ }
753
+
754
+ private async fetchRecordingGrant(
755
+ device: ConnectedDevice,
756
+ fetcher: RecordingGrantFetcher
757
+ ): Promise<string> {
758
+ const nonce = await this.readAuthNonce(device).catch(() => null);
759
+ return fetcher(nonce);
760
+ }
761
+
762
+ private async runRecordingControl(
763
+ device: ConnectedDevice,
764
+ grantBlob: string,
765
+ command: 'start' | 'stop'
766
+ ): Promise<{ success: boolean; error?: string }> {
767
+ const operation = command === 'start'
768
+ ? this.client.controls.requestStartRecording(device, grantBlob)
769
+ : this.client.controls.requestStopRecording(device, grantBlob);
770
+ const pendingState = operation.then(
771
+ () => this.readAndCacheRecordingState(device),
772
+ () => this.cachedRecordingState(device.id)
773
+ );
774
+ this.recordingStatePending.set(device.id, pendingState);
775
+ try {
776
+ const result = await operation;
777
+ await pendingState;
778
+ return result;
779
+ } catch (error) {
780
+ await pendingState;
781
+ throw error;
782
+ } finally {
783
+ if (this.recordingStatePending.get(device.id) === pendingState) {
784
+ this.recordingStatePending.delete(device.id);
785
+ }
786
+ }
787
+ }
788
+
789
+ private async readAndCacheRecordingState(
790
+ device: ConnectedDevice
791
+ ): Promise<RecordingState> {
792
+ const state = await this.client.controls.readRecordingState(device);
793
+ this.recordingStateCache.set(device.id, state);
794
+ return state;
795
+ }
796
+
797
+ private cachedRecordingState(deviceId: string): RecordingState {
798
+ return this.recordingStateCache.get(deviceId) ?? {
799
+ active: false,
800
+ initiatedBy: 'local',
801
+ };
802
+ }
803
+
804
+ private replaceOwned(
805
+ owner: Map<string, Promise<BotaAsyncEventSubscription>>,
806
+ deviceId: string,
807
+ next: Promise<BotaAsyncEventSubscription>
808
+ ): Promise<BotaAsyncEventSubscription> {
809
+ void this.removeOwned(owner, deviceId);
810
+ owner.set(deviceId, next);
811
+ void next.catch(() => {
812
+ if (owner.get(deviceId) === next) owner.delete(deviceId);
813
+ });
814
+ return next;
815
+ }
816
+
817
+ private async removeExpected(
818
+ owner: Map<string, Promise<BotaAsyncEventSubscription>>,
819
+ deviceId: string,
820
+ expected: Promise<BotaAsyncEventSubscription>
821
+ ): Promise<void> {
822
+ if (owner.get(deviceId) !== expected) return;
823
+ owner.delete(deviceId);
824
+ await (await expected).remove();
825
+ }
826
+
827
+ private async removeOwned(
828
+ owner: Map<string, Promise<BotaAsyncEventSubscription>>,
829
+ deviceId: string
830
+ ): Promise<void> {
831
+ const subscription = owner.get(deviceId);
832
+ if (!subscription) return;
833
+ owner.delete(deviceId);
834
+ await (await subscription).remove();
835
+ }
836
+
837
+ private assertAlive(): void {
838
+ if (this.destroyed) throw new Error('DeviceManager has been destroyed');
839
+ }
840
+ }
841
+
842
+ const idempotentRemoval = (remove: () => void): (() => void) => {
843
+ let removed = false;
844
+ return () => {
845
+ if (removed) return;
846
+ removed = true;
847
+ remove();
848
+ };
849
+ };
850
+
851
+ const mergeWiFiStatus = (
852
+ previous: WiFiStatusInfo | undefined,
853
+ patch: Partial<WiFiStatusInfo> | null | undefined
854
+ ): WiFiStatusInfo | undefined => {
855
+ if (patch === undefined) return previous;
856
+ if (patch === null) return undefined;
857
+ return {
858
+ status: patch.status ?? previous?.status ?? 'idle',
859
+ ...(patch.signalStrength ?? previous?.signalStrength) === undefined
860
+ ? {}
861
+ : { signalStrength: patch.signalStrength ?? previous?.signalStrength },
862
+ ...(patch.ssid ?? previous?.ssid) === undefined
863
+ ? {}
864
+ : { ssid: patch.ssid ?? previous?.ssid },
865
+ ...(patch.lastError ?? previous?.lastError) === undefined
866
+ ? {}
867
+ : { lastError: patch.lastError ?? previous?.lastError },
868
+ };
869
+ };
870
+
871
+ const asError = (error: unknown): Error =>
872
+ error instanceof Error ? error : new Error(String(error));
873
+
874
+ const environmentCode = (environment: Environment): number => {
875
+ switch (environment) {
876
+ case 'development': return 0;
877
+ case 'production': return 1;
878
+ case 'gamma': return 2;
879
+ }
880
+ };
881
+
882
+ const nativeProtocolStatus = (error: unknown): number | undefined => {
883
+ if (!error || typeof error !== 'object') return undefined;
884
+ const direct = Reflect.get(error, 'protocolStatus');
885
+ if (typeof direct === 'number') return direct;
886
+ const userInfo = Reflect.get(error, 'userInfo');
887
+ if (!userInfo || typeof userInfo !== 'object') return undefined;
888
+ const nested = Reflect.get(userInfo, 'protocolStatus');
889
+ return typeof nested === 'number' ? nested : undefined;
890
+ };
891
+
892
+ const isAlreadyPaired = (error: unknown): boolean =>
893
+ (error instanceof ProvisioningError && error.code === 'ALREADY_PAIRED') ||
894
+ nativeProtocolStatus(error) === 4;
895
+
896
+ const provisioningError = (
897
+ error: unknown,
898
+ deviceId: string
899
+ ): Error => {
900
+ if (error instanceof ProvisioningError) return error;
901
+ switch (nativeProtocolStatus(error)) {
902
+ case 1: return ProvisioningError.invalidToken(deviceId);
903
+ case 2: return ProvisioningError.storageError(deviceId);
904
+ case 3: return ProvisioningError.chunkError(deviceId);
905
+ case 4: return ProvisioningError.alreadyPaired(deviceId);
906
+ default: return asError(error);
907
+ }
908
+ };
909
+
910
+ let compatibilityResetSequence = 0;
911
+
912
+ const nextCompatibilityResetCommandId = (): string => {
913
+ compatibilityResetSequence += 1;
914
+ return `rn-reset-${Date.now().toString(36)}-${compatibilityResetSequence.toString(36)}`;
915
+ };
916
+
917
+ const factoryResetRejection = (
918
+ error: unknown
919
+ ): Extract<BleFactoryResetResult, { success: false }> | undefined => {
920
+ switch (nativeProtocolStatus(error)) {
921
+ case 1: return { success: false, error: 'invalid_token' };
922
+ case 2: return { success: false, error: 'storage_error' };
923
+ case 3: return { success: false, error: 'chunk_error' };
924
+ case 4: return { success: false, error: 'already_paired' };
925
+ default: return undefined;
926
+ }
927
+ };