@bota.dev/web-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.
- package/LICENSE +21 -0
- package/README.md +517 -0
- package/dist/capabilities.d.ts +8 -0
- package/dist/capabilities.js +21 -0
- package/dist/client.d.ts +39 -0
- package/dist/client.js +153 -0
- package/dist/controlManager.d.ts +38 -0
- package/dist/controlManager.js +344 -0
- package/dist/core.d.ts +698 -0
- package/dist/core.js +13 -0
- package/dist/deviceManager.d.ts +52 -0
- package/dist/deviceManager.js +491 -0
- package/dist/encryptedUploadV2Host.d.ts +239 -0
- package/dist/encryptedUploadV2Host.js +2136 -0
- package/dist/errors.d.ts +24 -0
- package/dist/errors.js +230 -0
- package/dist/gatt.d.ts +39 -0
- package/dist/gatt.js +57 -0
- package/dist/generated/bota_device_sdk_core.d.ts +202 -0
- package/dist/generated/bota_device_sdk_core.js +1025 -0
- package/dist/generated/bota_device_sdk_core_bg.wasm +0 -0
- package/dist/index.d.ts +13 -0
- package/dist/index.js +2 -0
- package/dist/indexedDbWorkflowStore.d.ts +52 -0
- package/dist/indexedDbWorkflowStore.js +763 -0
- package/dist/logManager.d.ts +24 -0
- package/dist/logManager.js +205 -0
- package/dist/models.d.ts +217 -0
- package/dist/models.js +1 -0
- package/dist/opfsBlobStore.d.ts +12 -0
- package/dist/opfsBlobStore.js +250 -0
- package/dist/otaManager.d.ts +49 -0
- package/dist/otaManager.js +951 -0
- package/dist/providerCancellation.d.ts +2 -0
- package/dist/providerCancellation.js +23 -0
- package/dist/providers.d.ts +138 -0
- package/dist/providers.js +1 -0
- package/dist/provisioningManager.d.ts +48 -0
- package/dist/provisioningManager.js +753 -0
- package/dist/recordingManager.d.ts +53 -0
- package/dist/recordingManager.js +1397 -0
- package/dist/storage.d.ts +103 -0
- package/dist/storage.js +60 -0
- package/dist/transport.d.ts +33 -0
- package/dist/transport.js +8 -0
- package/dist/wasmCore.d.ts +3 -0
- package/dist/wasmCore.js +1651 -0
- package/dist/webBluetoothTransport.d.ts +23 -0
- package/dist/webBluetoothTransport.js +369 -0
- package/dist/wifiManager.d.ts +54 -0
- package/dist/wifiManager.js +528 -0
- package/dist/workflowRuntime.d.ts +115 -0
- package/dist/workflowRuntime.js +1508 -0
- package/package.json +46 -0
|
@@ -0,0 +1,753 @@
|
|
|
1
|
+
import { BotaSDKError, normalizeCoreError, } from "./errors.js";
|
|
2
|
+
import { BOTA_CONTROL_SERVICE, BOTA_PROVISIONING_SERVICE, DEVICE_COMMAND_CHARACTERISTIC, DEVICE_INFORMATION_SERVICE, DEVICE_SETTINGS_CHARACTERISTIC, MODEL_NUMBER_CHARACTERISTIC, PROVISIONING_RESULT_CHARACTERISTIC, SERIAL_NUMBER_CHARACTERISTIC, canonicalGattUuid, } from "./gatt.js";
|
|
3
|
+
import { awaitProviderCall } from "./providerCancellation.js";
|
|
4
|
+
import { BrowserStorageError, } from "./storage.js";
|
|
5
|
+
import { BrowserTransportError, } from "./transport.js";
|
|
6
|
+
import { createBrowserPersistenceHost, } from "./workflowRuntime.js";
|
|
7
|
+
const DEFAULT_DEPROVISION_TIMEOUT_MS = 30_000;
|
|
8
|
+
export class ProvisioningManager {
|
|
9
|
+
core;
|
|
10
|
+
transport;
|
|
11
|
+
runtime;
|
|
12
|
+
devices;
|
|
13
|
+
storage;
|
|
14
|
+
provider;
|
|
15
|
+
now;
|
|
16
|
+
deprovisionTimeoutMs;
|
|
17
|
+
activeProvision = null;
|
|
18
|
+
destroyed = false;
|
|
19
|
+
destroyPromise = null;
|
|
20
|
+
constructor(options) {
|
|
21
|
+
this.core = options.core;
|
|
22
|
+
this.transport = options.transport;
|
|
23
|
+
this.runtime = options.runtime;
|
|
24
|
+
this.devices = options.devices;
|
|
25
|
+
this.storage = options.storage ?? null;
|
|
26
|
+
this.provider = options.provider ?? null;
|
|
27
|
+
this.now = options.now ?? Date.now;
|
|
28
|
+
this.deprovisionTimeoutMs =
|
|
29
|
+
options.deprovisionTimeoutMs ?? DEFAULT_DEPROVISION_TIMEOUT_MS;
|
|
30
|
+
}
|
|
31
|
+
async provision(request) {
|
|
32
|
+
this.validateProvisionRequest(request);
|
|
33
|
+
if (!this.provider) {
|
|
34
|
+
throw new BotaSDKError('unsupported_capability', 'provision');
|
|
35
|
+
}
|
|
36
|
+
if (!this.storage) {
|
|
37
|
+
throw new BotaSDKError('storage_unavailable', 'provision');
|
|
38
|
+
}
|
|
39
|
+
if (this.activeProvision) {
|
|
40
|
+
throw new BotaSDKError('operation_in_progress', 'provision');
|
|
41
|
+
}
|
|
42
|
+
let settle;
|
|
43
|
+
const active = {
|
|
44
|
+
controller: new AbortController(),
|
|
45
|
+
operationId: null,
|
|
46
|
+
physicalCompleted: false,
|
|
47
|
+
settled: new Promise((resolve) => {
|
|
48
|
+
settle = resolve;
|
|
49
|
+
}),
|
|
50
|
+
settle: () => settle(),
|
|
51
|
+
};
|
|
52
|
+
this.activeProvision = active;
|
|
53
|
+
const cancel = () => {
|
|
54
|
+
active.controller.abort();
|
|
55
|
+
if (active.operationId)
|
|
56
|
+
void this.runtime.cancel(active.operationId);
|
|
57
|
+
};
|
|
58
|
+
request.signal?.addEventListener('abort', cancel, { once: true });
|
|
59
|
+
if (request.signal?.aborted)
|
|
60
|
+
cancel();
|
|
61
|
+
try {
|
|
62
|
+
await this.runProvision(request.attemptId, active);
|
|
63
|
+
}
|
|
64
|
+
catch (error) {
|
|
65
|
+
const normalized = managerError(error, 'provision');
|
|
66
|
+
if (normalized.code === 'identity_mismatch')
|
|
67
|
+
await this.disconnectMismatch();
|
|
68
|
+
throw normalized;
|
|
69
|
+
}
|
|
70
|
+
finally {
|
|
71
|
+
request.signal?.removeEventListener('abort', cancel);
|
|
72
|
+
if (this.activeProvision === active)
|
|
73
|
+
this.activeProvision = null;
|
|
74
|
+
active.settle();
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
async deprovision(request) {
|
|
78
|
+
if (this.destroyed)
|
|
79
|
+
throw new BotaSDKError('cancelled', 'deprovision');
|
|
80
|
+
if (!(request.grant instanceof Uint8Array) || request.grant.byteLength === 0) {
|
|
81
|
+
throw new BotaSDKError('invalid_input', 'deprovision');
|
|
82
|
+
}
|
|
83
|
+
if (request.signal?.aborted) {
|
|
84
|
+
throw new BotaSDKError('cancelled', 'deprovision');
|
|
85
|
+
}
|
|
86
|
+
try {
|
|
87
|
+
return await this.runtime.runExclusive('deprovision', async (runtimeSignal) => await withCombinedSignal(runtimeSignal, request.signal, async (signal) => await this.performDeprovision(request.grant, signal)));
|
|
88
|
+
}
|
|
89
|
+
catch (error) {
|
|
90
|
+
const normalized = managerError(error, 'deprovision');
|
|
91
|
+
if (normalized.code === 'identity_mismatch')
|
|
92
|
+
await this.disconnectMismatch();
|
|
93
|
+
throw normalized;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
async readConnectionSettings() {
|
|
97
|
+
if (this.destroyed)
|
|
98
|
+
throw new BotaSDKError('cancelled', 'settings');
|
|
99
|
+
try {
|
|
100
|
+
return await this.runtime.runExclusive('settings', async (signal) => {
|
|
101
|
+
const { device } = await this.verifyConnectedDevice(signal, true, 'settings');
|
|
102
|
+
const encoded = await this.gattStep(this.transport.read(device, BOTA_PROVISIONING_SERVICE, DEVICE_SETTINGS_CHARACTERISTIC), signal, 'settings');
|
|
103
|
+
return publicSettings(this.core.decodeConnectionSettings(encoded));
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
catch (error) {
|
|
107
|
+
const normalized = managerError(error, 'settings');
|
|
108
|
+
if (normalized.code === 'identity_mismatch')
|
|
109
|
+
await this.disconnectMismatch();
|
|
110
|
+
throw normalized;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
async writeConnectionSettings(settings) {
|
|
114
|
+
if (this.destroyed)
|
|
115
|
+
throw new BotaSDKError('cancelled', 'settings');
|
|
116
|
+
try {
|
|
117
|
+
await this.runtime.runExclusive('settings', async (signal) => {
|
|
118
|
+
const { device, model } = await this.verifyConnectedDevice(signal, true, 'settings');
|
|
119
|
+
if (!model)
|
|
120
|
+
throw new BotaSDKError('protocol_error', 'settings');
|
|
121
|
+
let encoded = null;
|
|
122
|
+
try {
|
|
123
|
+
encoded = this.core.encodeConnectionSettings(coreSettings(settings), model);
|
|
124
|
+
await this.gattStep(this.transport.write(device, BOTA_PROVISIONING_SERVICE, DEVICE_SETTINGS_CHARACTERISTIC, encoded, true), signal, 'settings');
|
|
125
|
+
}
|
|
126
|
+
finally {
|
|
127
|
+
encoded?.fill(0);
|
|
128
|
+
}
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
catch (error) {
|
|
132
|
+
const normalized = managerError(error, 'settings');
|
|
133
|
+
if (normalized.code === 'identity_mismatch')
|
|
134
|
+
await this.disconnectMismatch();
|
|
135
|
+
throw normalized;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
destroy() {
|
|
139
|
+
if (this.destroyPromise)
|
|
140
|
+
return this.destroyPromise;
|
|
141
|
+
this.destroyed = true;
|
|
142
|
+
this.destroyPromise = (async () => {
|
|
143
|
+
const active = this.activeProvision;
|
|
144
|
+
if (!active)
|
|
145
|
+
return;
|
|
146
|
+
active.controller.abort();
|
|
147
|
+
if (active.operationId)
|
|
148
|
+
await this.runtime.cancel(active.operationId);
|
|
149
|
+
await active.settled;
|
|
150
|
+
})();
|
|
151
|
+
return this.destroyPromise;
|
|
152
|
+
}
|
|
153
|
+
async runProvision(attemptId, active) {
|
|
154
|
+
const storage = this.storage;
|
|
155
|
+
const provider = this.provider;
|
|
156
|
+
if (!storage || !provider) {
|
|
157
|
+
throw new BotaSDKError('unsupported_capability', 'provision');
|
|
158
|
+
}
|
|
159
|
+
const connected = this.requireConnected('provision');
|
|
160
|
+
let journal;
|
|
161
|
+
let journals;
|
|
162
|
+
try {
|
|
163
|
+
const listJournals = storage.listProvisioningJournals;
|
|
164
|
+
const loaded = await Promise.all([
|
|
165
|
+
storage.loadProvisioningJournal(attemptId),
|
|
166
|
+
listJournals ? listJournals.call(storage) : Promise.resolve([]),
|
|
167
|
+
]);
|
|
168
|
+
journal = loaded[0];
|
|
169
|
+
journals = loaded[1];
|
|
170
|
+
}
|
|
171
|
+
catch (error) {
|
|
172
|
+
throw managerError(error, 'provision');
|
|
173
|
+
}
|
|
174
|
+
if (journal) {
|
|
175
|
+
if (journal.serialNumber !== connected.serialNumber) {
|
|
176
|
+
throw new BotaSDKError('identity_mismatch', 'provision');
|
|
177
|
+
}
|
|
178
|
+
if (journal.phase === 'backend_confirmed')
|
|
179
|
+
return;
|
|
180
|
+
if (journal.phase === 'prepared') {
|
|
181
|
+
await this.requireProvisioningReconciliation(journal);
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
if (journal.phase !== 'device_applied') {
|
|
185
|
+
throw new BotaSDKError('resume_rejected', 'provision');
|
|
186
|
+
}
|
|
187
|
+
await this.confirmProvisioning(journal, active.controller.signal);
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
const unresolved = journals.find((candidate) => candidate.serialNumber === connected.serialNumber
|
|
191
|
+
&& candidate.attemptId !== attemptId
|
|
192
|
+
&& (candidate.phase === 'prepared' || candidate.phase === 'device_applied'));
|
|
193
|
+
if (unresolved) {
|
|
194
|
+
await this.requireProvisioningReconciliation(unresolved);
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
197
|
+
this.throwIfProvisionCancelled(active);
|
|
198
|
+
const cancellationId = randomBytes(16);
|
|
199
|
+
const operationId = `provision:${bytesHex(cancellationId)}`;
|
|
200
|
+
const materialId = `web-${bytesHex(randomBytes(16))}`;
|
|
201
|
+
active.operationId = operationId;
|
|
202
|
+
const host = new ProvisioningMaterialHost({
|
|
203
|
+
provider,
|
|
204
|
+
storage,
|
|
205
|
+
transport: this.transport,
|
|
206
|
+
runtime: this.runtime,
|
|
207
|
+
attemptId,
|
|
208
|
+
materialId,
|
|
209
|
+
serialNumber: connected.serialNumber,
|
|
210
|
+
now: this.now,
|
|
211
|
+
});
|
|
212
|
+
try {
|
|
213
|
+
await this.runtime.run(operationId, cancellationId, () => this.core.startProvisioning({
|
|
214
|
+
serialNumber: connected.serialNumber,
|
|
215
|
+
materialId,
|
|
216
|
+
cancellationId: cancellationId.slice(),
|
|
217
|
+
}), {
|
|
218
|
+
persistence: createBrowserPersistenceHost(storage),
|
|
219
|
+
hostMaterial: host,
|
|
220
|
+
}, undefined, async () => {
|
|
221
|
+
active.physicalCompleted = true;
|
|
222
|
+
const prepared = host.preparedJournal;
|
|
223
|
+
if (!prepared)
|
|
224
|
+
throw new BotaSDKError('internal_error', 'provision');
|
|
225
|
+
const deviceApplied = nextProvisioningJournal(prepared, 'device_applied', this.now);
|
|
226
|
+
await storage.saveProvisioningJournal(deviceApplied);
|
|
227
|
+
await this.confirmProvisioningWhileOwned(deviceApplied, active.controller.signal);
|
|
228
|
+
});
|
|
229
|
+
}
|
|
230
|
+
catch (error) {
|
|
231
|
+
const normalized = managerError(error, 'provision');
|
|
232
|
+
if (!active.physicalCompleted && host.prepareStarted) {
|
|
233
|
+
try {
|
|
234
|
+
await host.abort(normalized.code, active.controller.signal);
|
|
235
|
+
const prepared = host.preparedJournal;
|
|
236
|
+
if (prepared) {
|
|
237
|
+
await storage.saveProvisioningJournal(nextProvisioningJournal(prepared, 'aborted', this.now));
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
catch {
|
|
241
|
+
if (normalized.code === 'cancelled')
|
|
242
|
+
throw normalized;
|
|
243
|
+
throw new BotaSDKError('internal_error', 'provision', {
|
|
244
|
+
retryable: true,
|
|
245
|
+
});
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
throw normalized;
|
|
249
|
+
}
|
|
250
|
+
finally {
|
|
251
|
+
active.operationId = null;
|
|
252
|
+
cancellationId.fill(0);
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
async confirmProvisioning(journal, signal) {
|
|
256
|
+
try {
|
|
257
|
+
await this.runtime.runExclusive('provision', async (runtimeSignal) => await withCombinedSignal(runtimeSignal, signal, async (combined) => {
|
|
258
|
+
throwIfAborted(combined, 'provision');
|
|
259
|
+
await this.confirmProvisioningWhileOwned(journal, combined);
|
|
260
|
+
throwIfAborted(combined, 'provision');
|
|
261
|
+
}));
|
|
262
|
+
}
|
|
263
|
+
catch (error) {
|
|
264
|
+
const normalized = managerError(error, 'provision');
|
|
265
|
+
if (normalized.code === 'cancelled'
|
|
266
|
+
|| normalized.code === 'operation_in_progress'
|
|
267
|
+
|| normalized.code === 'storage_unavailable'
|
|
268
|
+
|| normalized.code === 'storage_quota_exceeded'
|
|
269
|
+
|| normalized.code === 'resume_rejected'
|
|
270
|
+
|| normalized.code === 'reconciliation_required') {
|
|
271
|
+
throw normalized;
|
|
272
|
+
}
|
|
273
|
+
throw new BotaSDKError('internal_error', 'provision', { retryable: true });
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
async confirmProvisioningWhileOwned(journal, signal) {
|
|
277
|
+
const provider = this.provider;
|
|
278
|
+
const storage = this.storage;
|
|
279
|
+
if (!provider || !storage) {
|
|
280
|
+
throw new BotaSDKError('unsupported_capability', 'provision');
|
|
281
|
+
}
|
|
282
|
+
try {
|
|
283
|
+
await awaitProviderCall(provider.confirm({
|
|
284
|
+
attemptId: journal.attemptId,
|
|
285
|
+
serialNumber: journal.serialNumber,
|
|
286
|
+
signal,
|
|
287
|
+
}), signal, 'provision');
|
|
288
|
+
}
|
|
289
|
+
catch (error) {
|
|
290
|
+
if (error instanceof BotaSDKError && error.code === 'cancelled')
|
|
291
|
+
throw error;
|
|
292
|
+
throw new BotaSDKError('internal_error', 'provision', { retryable: true });
|
|
293
|
+
}
|
|
294
|
+
try {
|
|
295
|
+
await storage.saveProvisioningJournal(nextProvisioningJournal(journal, 'backend_confirmed', this.now));
|
|
296
|
+
}
|
|
297
|
+
catch (error) {
|
|
298
|
+
throw managerError(error, 'provision');
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
async requireProvisioningReconciliation(journal) {
|
|
302
|
+
return await this.runtime.runExclusive('provision', async (signal) => {
|
|
303
|
+
const connected = this.requireConnected('provision');
|
|
304
|
+
if (journal.serialNumber !== connected.serialNumber) {
|
|
305
|
+
throw new BotaSDKError('identity_mismatch', 'provision');
|
|
306
|
+
}
|
|
307
|
+
await this.verifyConnectedDevice(signal, false, 'provision');
|
|
308
|
+
throw new BotaSDKError('reconciliation_required', 'provision', {
|
|
309
|
+
retryable: true,
|
|
310
|
+
});
|
|
311
|
+
});
|
|
312
|
+
}
|
|
313
|
+
async performDeprovision(callerGrant, signal) {
|
|
314
|
+
const { device } = await this.verifyConnectedDevice(signal, false, 'deprovision');
|
|
315
|
+
const grant = callerGrant.slice();
|
|
316
|
+
let command = null;
|
|
317
|
+
let subscription = null;
|
|
318
|
+
let resultWindowOpen = false;
|
|
319
|
+
let resolveResult;
|
|
320
|
+
const result = new Promise((resolve) => {
|
|
321
|
+
resolveResult = resolve;
|
|
322
|
+
});
|
|
323
|
+
let resultReceived = false;
|
|
324
|
+
try {
|
|
325
|
+
await this.gattStep(this.transport.write(device, BOTA_CONTROL_SERVICE, DEVICE_COMMAND_CHARACTERISTIC, grant, true), signal, 'deprovision');
|
|
326
|
+
const subscribe = await settled(this.transport.subscribe(device, BOTA_PROVISIONING_SERVICE, PROVISIONING_RESULT_CHARACTERISTIC, (notification) => {
|
|
327
|
+
if (!resultWindowOpen
|
|
328
|
+
|| resultReceived
|
|
329
|
+
|| canonicalGattUuid(notification.characteristicUuid)
|
|
330
|
+
!== canonicalGattUuid(PROVISIONING_RESULT_CHARACTERISTIC))
|
|
331
|
+
return;
|
|
332
|
+
resultReceived = true;
|
|
333
|
+
resolveResult(notification.value.slice());
|
|
334
|
+
}));
|
|
335
|
+
if (subscribe.kind === 'failed')
|
|
336
|
+
throw subscribe.error;
|
|
337
|
+
subscription = subscribe.value;
|
|
338
|
+
throwIfAborted(signal, 'deprovision');
|
|
339
|
+
command = this.core.encodeDeprovisionCommand();
|
|
340
|
+
await this.gattStep(this.transport.write(device, BOTA_CONTROL_SERVICE, DEVICE_COMMAND_CHARACTERISTIC, command, true), signal, 'deprovision');
|
|
341
|
+
resultWindowOpen = true;
|
|
342
|
+
const encodedResult = await resultWithDeadline(result, signal, this.deprovisionTimeoutMs);
|
|
343
|
+
return deprovisionResult(this.core.decodeDeprovisionResult(encodedResult));
|
|
344
|
+
}
|
|
345
|
+
finally {
|
|
346
|
+
resultWindowOpen = false;
|
|
347
|
+
resultReceived = true;
|
|
348
|
+
grant.fill(0);
|
|
349
|
+
command?.fill(0);
|
|
350
|
+
if (subscription)
|
|
351
|
+
await subscription.remove().catch(() => undefined);
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
async verifyConnectedDevice(signal, includeModel, operation) {
|
|
355
|
+
const connected = this.requireConnected(operation);
|
|
356
|
+
const device = this.runtime.connectedDeviceHandle;
|
|
357
|
+
if (!device || device.id !== connected.id) {
|
|
358
|
+
throw new BotaSDKError('device_disconnected', operation);
|
|
359
|
+
}
|
|
360
|
+
const serialBytes = await this.gattStep(this.transport.read(device, DEVICE_INFORMATION_SERVICE, SERIAL_NUMBER_CHARACTERISTIC), signal, operation);
|
|
361
|
+
if (decodeRequiredText(serialBytes, operation) !== connected.serialNumber) {
|
|
362
|
+
throw new BotaSDKError('identity_mismatch', operation);
|
|
363
|
+
}
|
|
364
|
+
if (!includeModel)
|
|
365
|
+
return { device, model: null };
|
|
366
|
+
const modelBytes = await this.gattStep(this.transport.read(device, DEVICE_INFORMATION_SERVICE, MODEL_NUMBER_CHARACTERISTIC), signal, operation);
|
|
367
|
+
const model = deviceModel(decodeRequiredText(modelBytes, operation));
|
|
368
|
+
if (!model)
|
|
369
|
+
throw new BotaSDKError('protocol_error', operation);
|
|
370
|
+
return { device, model };
|
|
371
|
+
}
|
|
372
|
+
requireConnected(operation) {
|
|
373
|
+
const connected = this.devices.connectedDevice;
|
|
374
|
+
if (!connected || this.runtime.connectedDeviceHandle?.id !== connected.id) {
|
|
375
|
+
throw new BotaSDKError('device_disconnected', operation);
|
|
376
|
+
}
|
|
377
|
+
return connected;
|
|
378
|
+
}
|
|
379
|
+
async gattStep(promise, signal, operation) {
|
|
380
|
+
const outcome = await settled(promise);
|
|
381
|
+
if (outcome.kind === 'failed')
|
|
382
|
+
throw outcome.error;
|
|
383
|
+
throwIfAborted(signal, operation);
|
|
384
|
+
return outcome.value;
|
|
385
|
+
}
|
|
386
|
+
validateProvisionRequest(request) {
|
|
387
|
+
if (this.destroyed)
|
|
388
|
+
throw new BotaSDKError('cancelled', 'provision');
|
|
389
|
+
if (typeof request.attemptId !== 'string' || request.attemptId.length === 0) {
|
|
390
|
+
throw new BotaSDKError('invalid_input', 'provision');
|
|
391
|
+
}
|
|
392
|
+
if (request.signal?.aborted) {
|
|
393
|
+
throw new BotaSDKError('cancelled', 'provision');
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
throwIfProvisionCancelled(active) {
|
|
397
|
+
if (this.destroyed || active.controller.signal.aborted) {
|
|
398
|
+
throw new BotaSDKError('cancelled', 'provision');
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
async disconnectMismatch() {
|
|
402
|
+
await this.devices.disconnect().catch(() => undefined);
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
class ProvisioningMaterialHost {
|
|
406
|
+
attemptId;
|
|
407
|
+
materialId;
|
|
408
|
+
serialNumber;
|
|
409
|
+
provider;
|
|
410
|
+
storage;
|
|
411
|
+
transport;
|
|
412
|
+
runtime;
|
|
413
|
+
now;
|
|
414
|
+
prepareAbortController = new AbortController();
|
|
415
|
+
cancelled = false;
|
|
416
|
+
currentContext = null;
|
|
417
|
+
currentMaterial = null;
|
|
418
|
+
prepareSettlement = null;
|
|
419
|
+
durableSettlement = null;
|
|
420
|
+
abortPromise = null;
|
|
421
|
+
didStartPrepare = false;
|
|
422
|
+
journal = null;
|
|
423
|
+
constructor(options) {
|
|
424
|
+
this.provider = options.provider;
|
|
425
|
+
this.storage = options.storage;
|
|
426
|
+
this.transport = options.transport;
|
|
427
|
+
this.runtime = options.runtime;
|
|
428
|
+
this.attemptId = options.attemptId;
|
|
429
|
+
this.materialId = options.materialId;
|
|
430
|
+
this.serialNumber = options.serialNumber;
|
|
431
|
+
this.now = options.now;
|
|
432
|
+
}
|
|
433
|
+
get prepareStarted() {
|
|
434
|
+
return this.didStartPrepare;
|
|
435
|
+
}
|
|
436
|
+
get preparedJournal() {
|
|
437
|
+
return this.journal ? { ...this.journal } : null;
|
|
438
|
+
}
|
|
439
|
+
async execute(envelope, context) {
|
|
440
|
+
if (envelope.effect.kind !== 'host_material_prepare_provisioning') {
|
|
441
|
+
throw new BotaSDKError('internal_error', 'provision');
|
|
442
|
+
}
|
|
443
|
+
const effect = envelope.effect;
|
|
444
|
+
const providerContext = {
|
|
445
|
+
attemptId: this.attemptId,
|
|
446
|
+
materialId: effect.materialId,
|
|
447
|
+
serialNumber: this.serialNumber,
|
|
448
|
+
nonce: effect.nonce.slice(),
|
|
449
|
+
devicePublicKey: effect.devicePublicKey.slice(),
|
|
450
|
+
signal: this.prepareAbortController.signal,
|
|
451
|
+
};
|
|
452
|
+
effect.nonce.fill(0);
|
|
453
|
+
effect.devicePublicKey.fill(0);
|
|
454
|
+
this.currentContext = providerContext;
|
|
455
|
+
const prepareLifecycle = deferredVoid();
|
|
456
|
+
try {
|
|
457
|
+
if (effect.materialId !== this.materialId) {
|
|
458
|
+
throw new BotaSDKError('internal_error', 'provision');
|
|
459
|
+
}
|
|
460
|
+
const device = this.runtime.connectedDeviceHandle;
|
|
461
|
+
if (!device)
|
|
462
|
+
throw new BotaSDKError('device_disconnected', 'provision');
|
|
463
|
+
const serialBytes = await this.transport.read(device, DEVICE_INFORMATION_SERVICE, SERIAL_NUMBER_CHARACTERISTIC);
|
|
464
|
+
this.throwIfCancelled(context.signal);
|
|
465
|
+
const freshSerial = decodeRequiredText(serialBytes, 'provision');
|
|
466
|
+
if (freshSerial !== this.serialNumber
|
|
467
|
+
|| effect.serialNumber !== this.serialNumber) {
|
|
468
|
+
throw new BotaSDKError('identity_mismatch', 'provision');
|
|
469
|
+
}
|
|
470
|
+
providerContext.serialNumber = freshSerial;
|
|
471
|
+
this.didStartPrepare = true;
|
|
472
|
+
this.prepareSettlement = prepareLifecycle.promise;
|
|
473
|
+
const pending = this.provider.prepare(providerContext);
|
|
474
|
+
let material;
|
|
475
|
+
try {
|
|
476
|
+
material = await awaitProviderCall(pending, this.prepareAbortController.signal, 'provision');
|
|
477
|
+
}
|
|
478
|
+
catch (error) {
|
|
479
|
+
if (this.prepareAbortController.signal.aborted) {
|
|
480
|
+
void pending.then(scrubMaterial).catch(() => undefined);
|
|
481
|
+
}
|
|
482
|
+
if (error instanceof BotaSDKError && error.code === 'cancelled')
|
|
483
|
+
throw error;
|
|
484
|
+
return {
|
|
485
|
+
requestId: envelope.requestId,
|
|
486
|
+
kind: 'host_material_failed',
|
|
487
|
+
platformCode: null,
|
|
488
|
+
};
|
|
489
|
+
}
|
|
490
|
+
this.currentMaterial = material;
|
|
491
|
+
this.throwIfCancelled(context.signal);
|
|
492
|
+
if (!validMaterial(material, this.materialId)) {
|
|
493
|
+
return {
|
|
494
|
+
requestId: envelope.requestId,
|
|
495
|
+
kind: 'host_material_failed',
|
|
496
|
+
platformCode: null,
|
|
497
|
+
};
|
|
498
|
+
}
|
|
499
|
+
const journal = {
|
|
500
|
+
schemaVersion: 1,
|
|
501
|
+
attemptId: this.attemptId,
|
|
502
|
+
materialId: this.materialId,
|
|
503
|
+
serialNumber: freshSerial,
|
|
504
|
+
phase: 'prepared',
|
|
505
|
+
updatedAtEpochMs: this.now(),
|
|
506
|
+
};
|
|
507
|
+
const save = this.storage.saveProvisioningJournal(journal).then(() => {
|
|
508
|
+
this.journal = journal;
|
|
509
|
+
});
|
|
510
|
+
this.durableSettlement = save.then(() => undefined, () => undefined);
|
|
511
|
+
await save;
|
|
512
|
+
this.throwIfCancelled(context.signal);
|
|
513
|
+
await context.dispatch({
|
|
514
|
+
requestId: envelope.requestId,
|
|
515
|
+
kind: 'provisioning_material_prepared',
|
|
516
|
+
apiEndpoint: material.apiEndpoint,
|
|
517
|
+
deviceToken: material.deviceToken,
|
|
518
|
+
mtu: material.mtu,
|
|
519
|
+
});
|
|
520
|
+
return null;
|
|
521
|
+
}
|
|
522
|
+
finally {
|
|
523
|
+
scrubContext(providerContext);
|
|
524
|
+
if (this.currentContext === providerContext)
|
|
525
|
+
this.currentContext = null;
|
|
526
|
+
if (this.currentMaterial)
|
|
527
|
+
scrubMaterial(this.currentMaterial);
|
|
528
|
+
this.currentMaterial = null;
|
|
529
|
+
prepareLifecycle.resolve();
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
async cancel() {
|
|
533
|
+
this.cancelled = true;
|
|
534
|
+
this.prepareAbortController.abort();
|
|
535
|
+
await this.prepareSettlement;
|
|
536
|
+
await this.durableSettlement;
|
|
537
|
+
if (this.currentContext)
|
|
538
|
+
scrubContext(this.currentContext);
|
|
539
|
+
if (this.currentMaterial)
|
|
540
|
+
scrubMaterial(this.currentMaterial);
|
|
541
|
+
}
|
|
542
|
+
abort(reason, signal) {
|
|
543
|
+
if (!this.abortPromise) {
|
|
544
|
+
this.abortPromise = awaitProviderCall(this.provider.abort({
|
|
545
|
+
attemptId: this.attemptId,
|
|
546
|
+
serialNumber: this.serialNumber,
|
|
547
|
+
reason,
|
|
548
|
+
signal,
|
|
549
|
+
}), signal, 'provision');
|
|
550
|
+
}
|
|
551
|
+
return this.abortPromise;
|
|
552
|
+
}
|
|
553
|
+
throwIfCancelled(signal) {
|
|
554
|
+
if (this.cancelled || signal.aborted) {
|
|
555
|
+
throw new BotaSDKError('cancelled', 'provision');
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
function nextProvisioningJournal(previous, phase, now) {
|
|
560
|
+
return {
|
|
561
|
+
schemaVersion: 1,
|
|
562
|
+
attemptId: previous.attemptId,
|
|
563
|
+
materialId: previous.materialId,
|
|
564
|
+
serialNumber: previous.serialNumber,
|
|
565
|
+
phase,
|
|
566
|
+
updatedAtEpochMs: Math.max(previous.updatedAtEpochMs, now()),
|
|
567
|
+
};
|
|
568
|
+
}
|
|
569
|
+
function validMaterial(material, expectedMaterialId) {
|
|
570
|
+
return typeof material.materialId === 'string'
|
|
571
|
+
&& material.materialId === expectedMaterialId
|
|
572
|
+
&& material.apiEndpoint instanceof Uint8Array
|
|
573
|
+
&& material.apiEndpoint.byteLength > 0
|
|
574
|
+
&& material.deviceToken instanceof Uint8Array
|
|
575
|
+
&& material.deviceToken.byteLength > 0
|
|
576
|
+
&& Number.isSafeInteger(material.mtu)
|
|
577
|
+
&& material.mtu > 0;
|
|
578
|
+
}
|
|
579
|
+
function scrubContext(context) {
|
|
580
|
+
context.nonce.fill(0);
|
|
581
|
+
context.devicePublicKey.fill(0);
|
|
582
|
+
}
|
|
583
|
+
function scrubMaterial(material) {
|
|
584
|
+
try {
|
|
585
|
+
if (typeof material !== 'object' || material === null)
|
|
586
|
+
return;
|
|
587
|
+
const candidate = material;
|
|
588
|
+
if (candidate.apiEndpoint instanceof Uint8Array) {
|
|
589
|
+
candidate.apiEndpoint.fill(0);
|
|
590
|
+
}
|
|
591
|
+
if (candidate.deviceToken instanceof Uint8Array) {
|
|
592
|
+
candidate.deviceToken.fill(0);
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
catch {
|
|
596
|
+
// Application-owned late values are untrusted and already ignored.
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
function coreSettings(settings) {
|
|
600
|
+
return {
|
|
601
|
+
enabledConnections: { ...settings.enabledConnections },
|
|
602
|
+
heartbeatEnabledConnections: { ...settings.heartbeatEnabledConnections },
|
|
603
|
+
uploadNetworkPreference: [...settings.uploadNetworkPreference],
|
|
604
|
+
powerManagement: { ...settings.powerManagement },
|
|
605
|
+
streamingEnabled: settings.streamingEnabled,
|
|
606
|
+
streamingFlushIntervalSeconds: settings.streamingFlushIntervalSeconds,
|
|
607
|
+
};
|
|
608
|
+
}
|
|
609
|
+
function publicSettings(settings) {
|
|
610
|
+
return {
|
|
611
|
+
enabledConnections: { ...settings.enabledConnections },
|
|
612
|
+
heartbeatEnabledConnections: { ...settings.heartbeatEnabledConnections },
|
|
613
|
+
uploadNetworkPreference: settings.uploadNetworkPreference.filter((connection) => connection === 'wifi' || connection === 'ble' || connection === 'cellular'),
|
|
614
|
+
powerManagement: { ...settings.powerManagement },
|
|
615
|
+
streamingEnabled: settings.streamingEnabled,
|
|
616
|
+
streamingFlushIntervalSeconds: settings.streamingFlushIntervalSeconds,
|
|
617
|
+
};
|
|
618
|
+
}
|
|
619
|
+
function deprovisionResult(result) {
|
|
620
|
+
if (result.success)
|
|
621
|
+
return { success: true };
|
|
622
|
+
const error = result.error === 'invalid_token'
|
|
623
|
+
|| result.error === 'storage_error'
|
|
624
|
+
|| result.error === 'chunk_error'
|
|
625
|
+
|| result.error === 'already_paired'
|
|
626
|
+
? result.error
|
|
627
|
+
: 'unknown';
|
|
628
|
+
return {
|
|
629
|
+
success: false,
|
|
630
|
+
error,
|
|
631
|
+
...(typeof result.errorRaw === 'number'
|
|
632
|
+
? { errorRaw: result.errorRaw }
|
|
633
|
+
: {}),
|
|
634
|
+
};
|
|
635
|
+
}
|
|
636
|
+
function deviceModel(value) {
|
|
637
|
+
switch (value.trim().toLowerCase().replace(/[ -]+/g, '_')) {
|
|
638
|
+
case 'bota_note':
|
|
639
|
+
case 'note':
|
|
640
|
+
return 'note';
|
|
641
|
+
case 'pin':
|
|
642
|
+
return 'pin';
|
|
643
|
+
case 'bota_pin':
|
|
644
|
+
case 'bota_pin_4g':
|
|
645
|
+
case 'bota_pin_pro':
|
|
646
|
+
case 'pin_4g':
|
|
647
|
+
case 'pin_pro':
|
|
648
|
+
return 'pin_4g';
|
|
649
|
+
default:
|
|
650
|
+
return null;
|
|
651
|
+
}
|
|
652
|
+
}
|
|
653
|
+
function decodeRequiredText(value, operation) {
|
|
654
|
+
try {
|
|
655
|
+
const decoded = new TextDecoder('utf-8', { fatal: true })
|
|
656
|
+
.decode(value)
|
|
657
|
+
.replace(/^[\0\s]+|[\0\s]+$/g, '');
|
|
658
|
+
if (!decoded)
|
|
659
|
+
throw new Error('empty');
|
|
660
|
+
return decoded;
|
|
661
|
+
}
|
|
662
|
+
catch {
|
|
663
|
+
throw new BotaSDKError('protocol_error', operation);
|
|
664
|
+
}
|
|
665
|
+
}
|
|
666
|
+
async function resultWithDeadline(result, signal, timeoutMs) {
|
|
667
|
+
if (signal.aborted)
|
|
668
|
+
throw new BotaSDKError('cancelled', 'deprovision');
|
|
669
|
+
let timer = null;
|
|
670
|
+
let cancel;
|
|
671
|
+
const cancellation = new Promise((_resolve, reject) => {
|
|
672
|
+
cancel = () => reject(new BotaSDKError('cancelled', 'deprovision'));
|
|
673
|
+
});
|
|
674
|
+
const timeout = new Promise((_resolve, reject) => {
|
|
675
|
+
timer = setTimeout(() => {
|
|
676
|
+
reject(new BotaSDKError('connection_failed', 'deprovision', {
|
|
677
|
+
retryable: true,
|
|
678
|
+
}));
|
|
679
|
+
}, timeoutMs);
|
|
680
|
+
});
|
|
681
|
+
signal.addEventListener('abort', cancel, { once: true });
|
|
682
|
+
try {
|
|
683
|
+
return await Promise.race([result, cancellation, timeout]);
|
|
684
|
+
}
|
|
685
|
+
finally {
|
|
686
|
+
signal.removeEventListener('abort', cancel);
|
|
687
|
+
if (timer)
|
|
688
|
+
clearTimeout(timer);
|
|
689
|
+
}
|
|
690
|
+
}
|
|
691
|
+
async function withCombinedSignal(primary, secondary, body) {
|
|
692
|
+
const controller = new AbortController();
|
|
693
|
+
const abort = () => controller.abort();
|
|
694
|
+
primary.addEventListener('abort', abort, { once: true });
|
|
695
|
+
secondary?.addEventListener('abort', abort, { once: true });
|
|
696
|
+
if (primary.aborted || secondary?.aborted)
|
|
697
|
+
controller.abort();
|
|
698
|
+
try {
|
|
699
|
+
return await body(controller.signal);
|
|
700
|
+
}
|
|
701
|
+
finally {
|
|
702
|
+
primary.removeEventListener('abort', abort);
|
|
703
|
+
secondary?.removeEventListener('abort', abort);
|
|
704
|
+
}
|
|
705
|
+
}
|
|
706
|
+
function throwIfAborted(signal, operation) {
|
|
707
|
+
if (signal.aborted)
|
|
708
|
+
throw new BotaSDKError('cancelled', operation);
|
|
709
|
+
}
|
|
710
|
+
async function settled(promise) {
|
|
711
|
+
return await promise.then((value) => ({ kind: 'completed', value }), (error) => ({ kind: 'failed', error }));
|
|
712
|
+
}
|
|
713
|
+
function deferredVoid() {
|
|
714
|
+
let resolve;
|
|
715
|
+
const promise = new Promise((resolvePromise) => {
|
|
716
|
+
resolve = resolvePromise;
|
|
717
|
+
});
|
|
718
|
+
return { promise, resolve };
|
|
719
|
+
}
|
|
720
|
+
function managerError(error, operation) {
|
|
721
|
+
if (error instanceof BotaSDKError) {
|
|
722
|
+
return new BotaSDKError(error.code, operation, {
|
|
723
|
+
retryable: error.retryable,
|
|
724
|
+
protocolStatus: error.protocolStatus,
|
|
725
|
+
});
|
|
726
|
+
}
|
|
727
|
+
if (error instanceof BrowserStorageError) {
|
|
728
|
+
return new BotaSDKError(error.code, operation);
|
|
729
|
+
}
|
|
730
|
+
if (error instanceof BrowserTransportError) {
|
|
731
|
+
const code = error.code === 'disconnected'
|
|
732
|
+
? 'device_disconnected'
|
|
733
|
+
: error.code === 'permission_denied'
|
|
734
|
+
? 'permission_denied'
|
|
735
|
+
: 'bluetooth_unavailable';
|
|
736
|
+
return new BotaSDKError(code, operation);
|
|
737
|
+
}
|
|
738
|
+
const normalized = normalizeCoreError(error, operation);
|
|
739
|
+
return new BotaSDKError(normalized.code, operation, {
|
|
740
|
+
retryable: normalized.retryable,
|
|
741
|
+
protocolStatus: normalized.protocolStatus,
|
|
742
|
+
});
|
|
743
|
+
}
|
|
744
|
+
function randomBytes(length) {
|
|
745
|
+
const value = new Uint8Array(length);
|
|
746
|
+
globalThis.crypto.getRandomValues(value);
|
|
747
|
+
return value;
|
|
748
|
+
}
|
|
749
|
+
function bytesHex(value) {
|
|
750
|
+
return [...value]
|
|
751
|
+
.map((byte) => byte.toString(16).padStart(2, '0'))
|
|
752
|
+
.join('');
|
|
753
|
+
}
|