@condorinst/mobile-bridge 0.22.0 → 0.23.1

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/dev.cjs CHANGED
@@ -39,6 +39,7 @@ __export(dev_exports, {
39
39
  createDevHarness: () => createDevHarness,
40
40
  createSimulatedNative: () => createSimulatedNative,
41
41
  formatLocalTimestamp: () => formatLocalTimestamp,
42
+ lightSensorsReading: () => lightSensorsReading,
42
43
  parseLocalTimestamp: () => parseLocalTimestamp,
43
44
  resolveScenario: () => resolveScenario,
44
45
  sensorLogPage: () => sensorLogPage,
@@ -98,7 +99,7 @@ function telemetry(spec, now) {
98
99
  serialNo: spec.serialNo,
99
100
  attemptDate: now - 10 * 6e4,
100
101
  message: spec.syncError,
101
- messageCode: "SYNC_FAILED"
102
+ messageCode: "send_data_log_error"
102
103
  }
103
104
  ] : []
104
105
  };
@@ -182,6 +183,7 @@ function systemStatus(overrides = {}) {
182
183
  internet: true,
183
184
  permission: true,
184
185
  hasDevices: true,
186
+ hasDevicesKnown: true,
185
187
  missingOptionalPermissions: [],
186
188
  missingEssentialPermissions: [],
187
189
  deviceSyncError: {},
@@ -197,13 +199,15 @@ function lightAt(epochMs, serialNo) {
197
199
  const photopic = Math.round((daylight * 1800 + noise * 40) * 10) / 10;
198
200
  return { photopic, melanopic: Math.round(photopic * 0.62 * 10) / 10 };
199
201
  }
200
- function logAt(epochMs, serialNo, deviceId) {
201
- const { photopic, melanopic } = lightAt(epochMs, serialNo);
202
+ function logAt(epochMs, serialNo, deviceId, bodyPart, docked) {
203
+ const { photopic, melanopic } = docked ? { photopic: 0, melanopic: 0 } : lightAt(epochMs, serialNo);
202
204
  return {
203
205
  id: Math.floor(epochMs / 1e3) * 10 + serialNo % 10,
204
206
  log_time: formatLocalTimestamp(new Date(epochMs)),
205
207
  log_type: 1,
206
- body_part: "wrist",
208
+ body_part: bodyPart,
209
+ // The device's raw wear flag; the name misleads: 1 = worn, 0 = on the dock.
210
+ off_wrist: docked ? 0 : 1,
207
211
  temperature: 31.5,
208
212
  lux_photopic: photopic,
209
213
  lux_melanopic: melanopic,
@@ -211,6 +215,67 @@ function logAt(epochMs, serialNo, deviceId) {
211
215
  act_data_pim: 120
212
216
  };
213
217
  }
218
+ function spectrum(nm, photopic) {
219
+ const out = {};
220
+ for (const w of nm) {
221
+ const weight = Math.exp(-(((w - 555) / 120) ** 2));
222
+ out[`wavelength${w}`] = Math.round(photopic * weight * 0.01 * 1e4) / 1e4;
223
+ }
224
+ return out;
225
+ }
226
+ var GEN1_NM = [390, 415, 445, 480, 515, 555, 590, 630, 680, 750];
227
+ var GEN2_NM = [395, 426, 457, 488, 520, 551, 582, 613, 644, 675, 707, 738, 769, 800];
228
+ function lightSensorsReading(device, epochMs = Date.now(), withDeviceId = true) {
229
+ const { photopic, melanopic } = lightAt(epochMs, device.serialNo);
230
+ const raw = {
231
+ light_sensor_f1: 0.01,
232
+ light_sensor_f2: 0.02,
233
+ light_sensor_f3: 0.03,
234
+ light_sensor_f4: 0.04,
235
+ light_sensor_f5: 0.05,
236
+ light_sensor_f6: 0.04,
237
+ light_sensor_f7: 0.03,
238
+ light_sensor_f8: 0.02,
239
+ light_sensor_clear: Math.round(photopic * 2e-4 * 1e4) / 1e4,
240
+ light_sensor_nir: 0.01
241
+ };
242
+ const address = {
243
+ serialNo: device.serialNo,
244
+ ...withDeviceId ? { deviceId: device.deviceId } : {}
245
+ };
246
+ if (device.modelId === "actlumus_plus") {
247
+ return {
248
+ ...address,
249
+ type: "actlumus2",
250
+ data: {
251
+ ...raw,
252
+ ...spectrum(GEN2_NM, photopic),
253
+ time: epochMs,
254
+ photopic,
255
+ melanopic,
256
+ light_sensor_z: 9e-3,
257
+ light_sensor_y: 0.038,
258
+ light_sensor_x: 0.044,
259
+ light_sensor_fd: 0
260
+ }
261
+ };
262
+ }
263
+ return {
264
+ ...address,
265
+ type: "actlumus",
266
+ data: {
267
+ ...raw,
268
+ ...spectrum(GEN1_NM, photopic),
269
+ time: epochMs,
270
+ photopic,
271
+ melanopic,
272
+ light_sensor_z: null,
273
+ light_sensor_y: null,
274
+ light_sensor_x: null,
275
+ light_sensor_fd: null
276
+ }
277
+ };
278
+ }
214
279
  var HISTORY_DAYS = 14;
215
280
  var encodeCursor = (epochMs) => `mock:${epochMs}`;
216
281
  var decodeCursor = (cursor) => Number(cursor.slice("mock:".length));
@@ -225,7 +290,15 @@ function sensorLogPage(query, opts = {}) {
225
290
  return { requestId: query.requestId, success: false, error: "CONFLICTING_CURSORS" };
226
291
  }
227
292
  const newest = Math.floor(now / periodMs) * periodMs;
228
- const oldest = newest - HISTORY_DAYS * 864e5;
293
+ const history = opts.history ?? {};
294
+ const oldest = newest - (history.days ?? HISTORY_DAYS) * 864e5;
295
+ const dock = new Set(history.dockHours ?? [7]);
296
+ const gapHours = new Set(history.gapHours ?? []);
297
+ const gapDates = new Set(history.gapDates ?? []);
298
+ const present = (t) => {
299
+ const d = new Date(t);
300
+ return !gapHours.has(d.getHours()) && !gapDates.has(formatLocalTimestamp(d).slice(0, 10));
301
+ };
229
302
  const rangeStart = query.startTime ? parseLocalTimestamp(query.startTime) : oldest;
230
303
  const rangeEnd = query.endTime ? parseLocalTimestamp(query.endTime) - 1 : newest;
231
304
  if (Number.isNaN(rangeStart) || Number.isNaN(rangeEnd) || rangeStart > rangeEnd + 1) {
@@ -233,33 +306,41 @@ function sensorLogPage(query, opts = {}) {
233
306
  }
234
307
  const first = Math.max(oldest, Math.ceil(rangeStart / periodMs) * periodMs);
235
308
  const last = Math.min(newest, Math.floor(rangeEnd / periodMs) * periodMs);
236
- let from;
237
- let to;
309
+ const walk = (start, step, max) => {
310
+ const out = [];
311
+ for (let t = start; t >= first && t <= last && out.length < max; t += step) {
312
+ if (present(t)) out.push(t);
313
+ }
314
+ return out;
315
+ };
316
+ let slots;
238
317
  if (query.endingBefore) {
239
- to = Math.min(last, decodeCursor(query.endingBefore) - periodMs);
240
- from = Math.max(first, to - (limit - 1) * periodMs);
318
+ slots = walk(
319
+ Math.min(last, decodeCursor(query.endingBefore) - periodMs),
320
+ -periodMs,
321
+ limit
322
+ ).reverse();
241
323
  } else if (query.startingAfter) {
242
- from = Math.max(first, decodeCursor(query.startingAfter) + periodMs);
243
- to = Math.min(last, from + (limit - 1) * periodMs);
324
+ slots = walk(Math.max(first, decodeCursor(query.startingAfter) + periodMs), periodMs, limit);
244
325
  } else if (query.startTime) {
245
- from = first;
246
- to = Math.min(last, from + (limit - 1) * periodMs);
326
+ slots = walk(first, periodMs, limit);
247
327
  } else {
248
- to = last;
249
- from = Math.max(first, to - (limit - 1) * periodMs);
250
- }
251
- const logs = [];
252
- for (let t = from; t <= to; t += periodMs) {
253
- logs.push(logAt(t, query.serialNo, opts.deviceId ?? 0));
328
+ slots = walk(last, -periodMs, limit).reverse();
254
329
  }
330
+ const bodyPart = opts.bodyPart ?? "wrist";
331
+ const logs = slots.map(
332
+ (t) => logAt(t, query.serialNo, opts.deviceId ?? 0, bodyPart, dock.has(new Date(t).getHours()))
333
+ );
334
+ const from = slots[0] ?? 0;
335
+ const to = slots[slots.length - 1] ?? 0;
255
336
  return {
256
337
  requestId: query.requestId,
257
338
  success: true,
258
339
  logs,
259
340
  startingAfter: logs.length ? encodeCursor(to) : null,
260
341
  endingBefore: logs.length ? encodeCursor(from) : null,
261
- hasMore: logs.length > 0 && to < last,
262
- hasPrevious: logs.length > 0 && from > first
342
+ hasMore: logs.length > 0 && walk(to + periodMs, periodMs, 1).length > 0,
343
+ hasPrevious: logs.length > 0 && walk(from - periodMs, -periodMs, 1).length > 0
263
344
  };
264
345
  }
265
346
 
@@ -1253,6 +1334,19 @@ var BridgeShell = class {
1253
1334
  }
1254
1335
  };
1255
1336
 
1337
+ // src/version.ts
1338
+ function versionAtLeast(version, min) {
1339
+ if (!version) return false;
1340
+ const a = version.split(".").map((n) => Number.parseInt(n, 10) || 0);
1341
+ const b = min.split(".").map((n) => Number.parseInt(n, 10) || 0);
1342
+ for (let i = 0; i < 3; i++) {
1343
+ const x = a[i] ?? 0;
1344
+ const y = b[i] ?? 0;
1345
+ if (x !== y) return x > y;
1346
+ }
1347
+ return true;
1348
+ }
1349
+
1256
1350
  // src/dev/policy.ts
1257
1351
  var CANVAS_POLICY = {
1258
1352
  readOnlyKeys: [
@@ -1288,28 +1382,27 @@ var CANVAS_POLICY = {
1288
1382
  var CANVAS_CSP = "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self' data:; connect-src 'self'; frame-src 'self'; object-src 'none'; base-uri 'none'; form-action 'none';";
1289
1383
 
1290
1384
  // src/dev/scenarios.ts
1291
- var HARNESS_NATIVE_VERSION = "0.17.0";
1385
+ var HARNESS_NATIVE_VERSION = "0.18.0";
1292
1386
  var base = {
1293
- description: "Two devices, background service already running, realistic latency.",
1387
+ description: "Two devices, background service already running, realistic latency. The first device starts a BLE session 3 s after load; the second stays between sessions.",
1294
1388
  nativeVersion: HARNESS_NATIVE_VERSION,
1295
1389
  latencyMs: [80, 250],
1296
1390
  serviceStartMs: 0,
1297
- userDeviceSilent: false,
1298
1391
  logPeriodS: 60,
1299
1392
  systemStatus: systemStatus(),
1300
1393
  devices: () => twoDevices()
1301
1394
  };
1302
1395
  var SCENARIOS = {
1303
- default: base,
1396
+ default: { ...base, firstSessionAfterMs: 3e3 },
1304
1397
  "cold-start": {
1305
1398
  ...base,
1306
- description: "App launched from cold: the background service takes 4 s to start, and every ping/userDevice sent before that is lost. A bundle that asks once stays on its loading screen forever.",
1399
+ description: "App launched from cold: the background service takes 4 s to start, and every ping/userDevice sent before that is lost. From nativeVersion 0.18.0 the app re-sends the lost userDevice itself; below it, a bundle that asks once stays on its loading screen forever.",
1307
1400
  serviceStartMs: 4e3
1308
1401
  },
1309
1402
  "no-devices": {
1310
1403
  ...base,
1311
- description: "Account with no linked device: userDevice never answers and systemStatus.hasDevices is false. Your bundle must show an empty state.",
1312
- userDeviceSilent: true,
1404
+ description: "Account with no linked device: userDevice answers with error NO_DEVICES (below nativeVersion 0.18.0: never answers) and systemStatus.hasDevices is false. Your bundle must show an empty state.",
1405
+ userDeviceError: "NO_DEVICES",
1313
1406
  systemStatus: systemStatus({ hasDevices: false })
1314
1407
  },
1315
1408
  "shared-serial": {
@@ -1319,9 +1412,8 @@ var SCENARIOS = {
1319
1412
  },
1320
1413
  offline: {
1321
1414
  ...base,
1322
- description: "No internet. systemStatus.internet is false and sensorLog fails; userDevice answers from the cache.",
1323
- sensorLogError: "Connection failed",
1324
- systemStatus: systemStatus({ internet: false })
1415
+ description: "No internet. systemStatus.internet is false and sensorLog fails; userDevice answers from the cache. The internet comes back when a test calls window.condorDev.setStatus({ internet: true }).",
1416
+ systemStatus: systemStatus({ internet: false, hasDevicesKnown: false })
1325
1417
  }
1326
1418
  };
1327
1419
  function resolveScenario(scenario) {
@@ -1340,6 +1432,7 @@ function resolveScenario(scenario) {
1340
1432
 
1341
1433
  // src/dev/harness.ts
1342
1434
  var DEV_HARNESS_MARKER = "__condorDevHarness__";
1435
+ var STEP_MS = 200;
1343
1436
  function createSimulatedNative(adapter, scenario, hooks = {}) {
1344
1437
  const now = hooks.now ?? Date.now;
1345
1438
  const startedAt = now();
@@ -1347,6 +1440,26 @@ function createSimulatedNative(adapter, scenario, hooks = {}) {
1347
1440
  const timers = /* @__PURE__ */ new Set();
1348
1441
  let status = { ...scenario.systemStatus };
1349
1442
  let canGoBack = false;
1443
+ const alwaysReplies = versionAtLeast(scenario.nativeVersion, "0.18.0");
1444
+ let pendingUserDevice = false;
1445
+ let sensorLogCount = 0;
1446
+ const roster = scenario.devices().devicesConfig;
1447
+ const connected = new Map(roster.map((d) => [d.deviceId, false]));
1448
+ const subscribed = /* @__PURE__ */ new Set();
1449
+ const realTimeOn = /* @__PURE__ */ new Set();
1450
+ const streams = /* @__PURE__ */ new Map();
1451
+ const byId = versionAtLeast(scenario.nativeVersion, "0.17.0");
1452
+ const pendingSync = /* @__PURE__ */ new Set();
1453
+ const pendingErase = /* @__PURE__ */ new Set();
1454
+ const running = /* @__PURE__ */ new Set();
1455
+ const sessionStart = /* @__PURE__ */ new Map();
1456
+ const idOf = (device) => byId && roster.filter((d) => d.serialNo === device.serialNo).length > 1 ? { deviceId: device.deviceId } : {};
1457
+ const findDevice = (args) => args.deviceId !== void 0 ? roster.find((d) => d.deviceId === args.deviceId) : roster.find((d) => d.serialNo === args.serialNo);
1458
+ const statusForBundle = () => {
1459
+ if (alwaysReplies) return status;
1460
+ const { hasDevicesKnown: _omitted, ...older } = status;
1461
+ return older;
1462
+ };
1350
1463
  const record2 = (entry) => {
1351
1464
  const full = { at: now(), ...entry };
1352
1465
  log.push(full);
@@ -1361,12 +1474,34 @@ function createSimulatedNative(adapter, scenario, hooks = {}) {
1361
1474
  const reply = (key, payload, delay = latency()) => {
1362
1475
  const timer = setTimeout(() => {
1363
1476
  timers.delete(timer);
1364
- record2({ kind: "reply", key, detail: `${key} \u2192 bundle` });
1477
+ if (typeof payload === "function") payload = payload();
1478
+ record2({ kind: "reply", key, detail: `${key} \u2192 bundle`, payload });
1365
1479
  adapter.triggerEmit(key, payload);
1366
1480
  }, delay);
1367
1481
  timers.add(timer);
1368
1482
  };
1369
1483
  const serviceUp = () => now() - startedAt >= scenario.serviceStartMs;
1484
+ const answerUserDevice = () => {
1485
+ if (scenario.userDeviceError) {
1486
+ if (!alwaysReplies) {
1487
+ record2({
1488
+ kind: "dropped",
1489
+ command: "userDevice",
1490
+ detail: `userDevice unanswered: the lookup failed (${scenario.userDeviceError}) and this app version replies only on success`
1491
+ });
1492
+ return;
1493
+ }
1494
+ reply("userDevice", {
1495
+ lastUpdate: now(),
1496
+ devicesConfig: [],
1497
+ devicesTelemetry: [],
1498
+ devicesUserConfig: [],
1499
+ error: scenario.userDeviceError
1500
+ });
1501
+ return;
1502
+ }
1503
+ reply("userDevice", scenario.devices());
1504
+ };
1370
1505
  const language = () => {
1371
1506
  const deviceLocale = typeof navigator !== "undefined" ? navigator.language : "en-US";
1372
1507
  const lang = deviceLocale.slice(0, 2);
@@ -1383,7 +1518,7 @@ function createSimulatedNative(adapter, scenario, hooks = {}) {
1383
1518
  if (command !== "handshake") {
1384
1519
  const shown = JSON.stringify(args);
1385
1520
  const detail = shown === "{}" ? command : `${command} ${shown.length > 160 ? `${shown.slice(0, 157)}...` : shown}`;
1386
- record2({ kind: "sent", command, detail });
1521
+ record2({ kind: "sent", command, detail, args });
1387
1522
  }
1388
1523
  switch (command) {
1389
1524
  case "handshake":
@@ -1395,8 +1530,8 @@ function createSimulatedNative(adapter, scenario, hooks = {}) {
1395
1530
  return;
1396
1531
  }
1397
1532
  reply("handshake-ack", { nativeVersion: scenario.nativeVersion, accepted: true });
1398
- reply("systemStatus", status, latency() + 20);
1399
- reply("language", language(), latency() + 30);
1533
+ reply("systemStatus", statusForBundle, latency() + 20);
1534
+ reply("language", language, latency() + 30);
1400
1535
  return;
1401
1536
  case "ping":
1402
1537
  if (!serviceUp()) {
@@ -1411,30 +1546,33 @@ function createSimulatedNative(adapter, scenario, hooks = {}) {
1411
1546
  return;
1412
1547
  case "userDevice":
1413
1548
  if (!serviceUp()) {
1549
+ pendingUserDevice = alwaysReplies;
1414
1550
  record2({
1415
1551
  kind: "dropped",
1416
1552
  command,
1417
- detail: "userDevice lost: the background service is not running yet. Ask again."
1418
- });
1419
- return;
1420
- }
1421
- if (scenario.userDeviceSilent) {
1422
- record2({
1423
- kind: "dropped",
1424
- command,
1425
- detail: "userDevice unanswered: the account has no linked device (the app replies only on success)"
1553
+ detail: alwaysReplies ? "userDevice lost: the background service is not running yet; the app re-sends it when the service is up" : "userDevice lost: the background service is not running yet. Ask again."
1426
1554
  });
1427
1555
  return;
1428
1556
  }
1429
- reply("userDevice", scenario.devices());
1557
+ answerUserDevice();
1430
1558
  return;
1431
1559
  case "sensorLog": {
1432
1560
  const query = args;
1433
- if (scenario.sensorLogError) {
1561
+ sensorLogCount += 1;
1562
+ if (scenario.sensorLogFailOnRequest === sensorLogCount) {
1434
1563
  reply("sensorLog", {
1435
1564
  requestId: query.requestId,
1436
1565
  success: false,
1437
- error: scenario.sensorLogError
1566
+ error: "Connection failed"
1567
+ });
1568
+ return;
1569
+ }
1570
+ const logError = scenario.sensorLogError ?? (status.internet === false ? "Connection failed" : void 0);
1571
+ if (logError) {
1572
+ reply("sensorLog", {
1573
+ requestId: query.requestId,
1574
+ success: false,
1575
+ error: logError
1438
1576
  });
1439
1577
  return;
1440
1578
  }
@@ -1453,12 +1591,29 @@ function createSimulatedNative(adapter, scenario, hooks = {}) {
1453
1591
  sensorLogPage(query, {
1454
1592
  now: now(),
1455
1593
  periodS: scenario.logPeriodS,
1456
- deviceId: device?.deviceId
1594
+ deviceId: device?.deviceId,
1595
+ bodyPart: device?.deviceBodyPart ?? "wrist",
1596
+ history: scenario.history
1457
1597
  })
1458
1598
  );
1459
1599
  return;
1460
1600
  }
1461
- case "realTime":
1601
+ case "realTime": {
1602
+ const device = findDevice(args);
1603
+ if (device) {
1604
+ if (args.value === true) realTimeOn.add(device.deviceId);
1605
+ else realTimeOn.delete(device.deviceId);
1606
+ syncStream(device.deviceId);
1607
+ if (args.value !== true && connected.get(device.deviceId) && !running.has(device.deviceId)) {
1608
+ endSession(device.deviceId, latency());
1609
+ }
1610
+ if (args.value === true && !connected.get(device.deviceId)) {
1611
+ record2({
1612
+ kind: "info",
1613
+ detail: `realTime on for device ${device.deviceId}, which is between BLE sessions: readings start when it connects`
1614
+ });
1615
+ }
1616
+ }
1462
1617
  reply("realTime", {
1463
1618
  serialNo: args.serialNo,
1464
1619
  ...args.deviceId !== void 0 ? { deviceId: args.deviceId } : {},
@@ -1466,23 +1621,37 @@ function createSimulatedNative(adapter, scenario, hooks = {}) {
1466
1621
  value: args.value === true
1467
1622
  });
1468
1623
  return;
1624
+ }
1469
1625
  case "syncTime":
1470
1626
  case "deleteMemory":
1627
+ {
1628
+ const target = findDevice(args);
1629
+ if (target) (command === "syncTime" ? pendingSync : pendingErase).add(target.deviceId);
1630
+ }
1631
+ record2({
1632
+ kind: "info",
1633
+ detail: command === "deleteMemory" ? "deleteMemory recorded: the app erases the memory in the next sync, after the data is uploaded" : "syncTime recorded: the app sets the clock in the next sync"
1634
+ });
1471
1635
  reply(command, {
1472
1636
  serialNo: args.serialNo,
1473
1637
  ...args.deviceId !== void 0 ? { deviceId: args.deviceId } : {},
1474
1638
  success: true
1475
1639
  });
1476
1640
  return;
1477
- case "deviceConnection":
1478
- if (args.type === "add") {
1641
+ case "deviceConnection": {
1642
+ const device = findDevice(args);
1643
+ if (!device) {
1479
1644
  reply("deviceConnection", {
1480
1645
  serialNo: args.serialNo,
1481
1646
  ...args.deviceId !== void 0 ? { deviceId: args.deviceId } : {},
1482
- isConnect: true
1647
+ isConnect: false
1483
1648
  });
1649
+ return;
1484
1650
  }
1651
+ if (args.type === "add") subscribed.add(device.deviceId);
1652
+ else if (args.type === "remove") subscribed.delete(device.deviceId);
1485
1653
  return;
1654
+ }
1486
1655
  case "language":
1487
1656
  reply("language", language());
1488
1657
  return;
@@ -1493,7 +1662,7 @@ function createSimulatedNative(adapter, scenario, hooks = {}) {
1493
1662
  missingEssentialPermissions: status.missingEssentialPermissions.filter((p) => p !== name),
1494
1663
  missingOptionalPermissions: status.missingOptionalPermissions.filter((p) => p !== name)
1495
1664
  };
1496
- reply("systemStatus", status, latency() + 400);
1665
+ reply("systemStatus", statusForBundle, latency() + 400);
1497
1666
  return;
1498
1667
  }
1499
1668
  case "jsError":
@@ -1517,11 +1686,170 @@ function createSimulatedNative(adapter, scenario, hooks = {}) {
1517
1686
  return;
1518
1687
  }
1519
1688
  };
1689
+ function emitConnection(deviceId) {
1690
+ if (!subscribed.has(deviceId)) return;
1691
+ const device = roster.find((d) => d.deviceId === deviceId);
1692
+ if (!device) return;
1693
+ reply("deviceConnection", {
1694
+ serialNo: device.serialNo,
1695
+ ...idOf(device),
1696
+ isConnect: connected.get(deviceId) === true
1697
+ });
1698
+ }
1699
+ function syncStream(deviceId) {
1700
+ const want = realTimeOn.has(deviceId) && connected.get(deviceId) === true;
1701
+ const running2 = streams.get(deviceId);
1702
+ if (want && !running2) {
1703
+ const device = roster.find((d) => d.deviceId === deviceId);
1704
+ if (!device) return;
1705
+ streams.set(
1706
+ deviceId,
1707
+ setInterval(() => {
1708
+ adapter.triggerEmit(
1709
+ "deviceLightSensors",
1710
+ lightSensorsReading(device, now(), "deviceId" in idOf(device))
1711
+ );
1712
+ }, 1e3)
1713
+ );
1714
+ } else if (!want && running2) {
1715
+ clearInterval(running2);
1716
+ streams.delete(deviceId);
1717
+ }
1718
+ }
1719
+ function setConnected(deviceId, value) {
1720
+ if (!connected.has(deviceId)) {
1721
+ record2({ kind: "info", detail: `setConnected: no device ${deviceId} in this scenario` });
1722
+ return;
1723
+ }
1724
+ if (connected.get(deviceId) === value) return;
1725
+ connected.set(deviceId, value);
1726
+ record2({
1727
+ kind: "info",
1728
+ detail: `device ${deviceId}: BLE session ${value ? "started" : "ended"}`
1729
+ });
1730
+ emitConnection(deviceId);
1731
+ syncStream(deviceId);
1732
+ const device = roster.find((d) => d.deviceId === deviceId);
1733
+ if (device) runSession(device, value);
1734
+ }
1735
+ function setSyncError(device, text, delay) {
1736
+ const timer = setTimeout(() => {
1737
+ timers.delete(timer);
1738
+ const byId2 = { ...status.deviceSyncErrorById ?? {} };
1739
+ const bySerial = { ...status.deviceSyncError };
1740
+ const shared = roster.filter((d) => d.serialNo === device.serialNo).length > 1;
1741
+ if (text === null) {
1742
+ if (!(device.deviceId in byId2)) return;
1743
+ delete byId2[device.deviceId];
1744
+ if (!shared) delete bySerial[device.serialNo];
1745
+ } else {
1746
+ byId2[device.deviceId] = text;
1747
+ if (!shared) bySerial[device.serialNo] = text;
1748
+ }
1749
+ status = { ...status, deviceSyncErrorById: byId2, deviceSyncError: bySerial };
1750
+ reply("systemStatus", statusForBundle, 0);
1751
+ }, delay);
1752
+ timers.add(timer);
1753
+ }
1754
+ function runSession(device, start) {
1755
+ const note = (message, messageCode, isError = false) => ({
1756
+ serialNo: device.serialNo,
1757
+ ...idOf(device),
1758
+ isConnect: !isError,
1759
+ message,
1760
+ messageCode,
1761
+ isError
1762
+ });
1763
+ if (!start) {
1764
+ reply("deviceNotification", { ...note("Device disconnected", null), isConnect: false });
1765
+ return;
1766
+ }
1767
+ const id = device.deviceId;
1768
+ const steps = [
1769
+ ["[Start] Enable BLE periodic", "enable_ble_periodic_notification", "enable_ble_periodic"],
1770
+ ["[Not needed] Enable BLE periodic", "enable_ble_periodic_not_needed_notification", ""],
1771
+ ["[Start] Set BLE period", "set_ble_period_notification", "set_ble_period"],
1772
+ ["[Not needed] Set BLE period", "set_ble_period_not_needed_notification", ""],
1773
+ ["[Start] Set sync time", "set_sync_time_notification", "set_sync_time"],
1774
+ ...pendingSync.has(id) ? [] : [["[Not needed] Set sync time", "set_sync_time_not_needed_notification", ""]],
1775
+ ["[Start] Get data log", "get_data_log_notification", "get_data_log"],
1776
+ ["[Start] Send data log", "send_data_log_notification", "send_data_log"],
1777
+ [
1778
+ "[Success] Set last data log sent to server",
1779
+ "set_last_data_log_sent_to_server_notification",
1780
+ ""
1781
+ ],
1782
+ ["[Start] Get system log", "get_system_log_notification", "get_system_log"],
1783
+ ["[Start] Send system log", "send_system_log_notification", "send_system_log"],
1784
+ ["[Start] Get full charge log", "get_full_charge_log_notification", "get_full_charge_log"],
1785
+ ["[Start] Send full charge log", "send_full_charge_log_notification", "send_full_charge_log"],
1786
+ ["[Start] Set erase memory", "set_erase_memory_notification", "set_erase_memory"],
1787
+ ...pendingErase.has(id) ? [["[Start] Erase memory", "erase_memory_notification", ""]] : [],
1788
+ ["[Start] Get device telemetry", "get_device_telemetry_notification", "get_device_telemetry"],
1789
+ ["[Start] Save device telemetry", "save_device_telemetry_notification", ""]
1790
+ ];
1791
+ const failAt = scenario.sessionFailAt ?? (status.internet === false ? "send_data_log" : void 0);
1792
+ running.add(id);
1793
+ sessionStart.set(id, now());
1794
+ let delay = latency();
1795
+ reply("deviceNotification", note("Device connected", null), delay);
1796
+ for (const [message, code, stage] of steps) {
1797
+ delay += STEP_MS;
1798
+ reply("deviceNotification", note(message, code), delay);
1799
+ if (stage && failAt === stage) {
1800
+ delay += STEP_MS;
1801
+ const what = message.replace(/^\[\w+\] /, "");
1802
+ reply("deviceNotification", note(`[Error] ${what}`, `${stage}_error`, true), delay);
1803
+ setSyncError(device, `Sync failed (${stage}_error)`, delay + 20);
1804
+ endSession(id, delay + STEP_MS);
1805
+ return;
1806
+ }
1807
+ if (stage === "set_sync_time") pendingSync.delete(id);
1808
+ }
1809
+ delay += STEP_MS;
1810
+ reply("deviceNotification", note("[Done] Pipeline", "pipeline_done_notification"), delay);
1811
+ setSyncError(device, null, delay + 20);
1812
+ pendingErase.delete(id);
1813
+ endSession(id, delay + STEP_MS);
1814
+ }
1815
+ function endSession(deviceId, delay) {
1816
+ const timer = setTimeout(() => {
1817
+ timers.delete(timer);
1818
+ running.delete(deviceId);
1819
+ if (!realTimeOn.has(deviceId)) {
1820
+ setConnected(deviceId, false);
1821
+ return;
1822
+ }
1823
+ const startedAt2 = sessionStart.get(deviceId) ?? now();
1824
+ const hold = setTimeout(
1825
+ () => {
1826
+ timers.delete(hold);
1827
+ if (connected.get(deviceId) && !running.has(deviceId)) setConnected(deviceId, false);
1828
+ },
1829
+ Math.max(0, startedAt2 + (scenario.realTimeSessionMs ?? 18e4) - now())
1830
+ );
1831
+ timers.add(hold);
1832
+ }, delay);
1833
+ timers.add(timer);
1834
+ }
1520
1835
  const unsubscribe = adapter.onCallHandler(handle);
1836
+ const first = roster[0];
1837
+ if (first && typeof scenario.firstSessionAfterMs === "number") {
1838
+ const timer = setTimeout(() => {
1839
+ timers.delete(timer);
1840
+ setConnected(first.deviceId, true);
1841
+ }, scenario.firstSessionAfterMs);
1842
+ timers.add(timer);
1843
+ }
1521
1844
  if (scenario.serviceStartMs > 0) {
1522
1845
  const timer = setTimeout(() => {
1523
1846
  timers.delete(timer);
1524
1847
  record2({ kind: "info", detail: "background service is up: ping and userDevice now answer" });
1848
+ if (pendingUserDevice) {
1849
+ pendingUserDevice = false;
1850
+ record2({ kind: "info", detail: "the app re-sent the pending userDevice" });
1851
+ answerUserDevice();
1852
+ }
1525
1853
  }, scenario.serviceStartMs);
1526
1854
  timers.add(timer);
1527
1855
  }
@@ -1537,8 +1865,15 @@ function createSimulatedNative(adapter, scenario, hooks = {}) {
1537
1865
  }
1538
1866
  reply("navigationBack", {}, 0);
1539
1867
  },
1868
+ setStatus(patch) {
1869
+ status = { ...status, ...patch };
1870
+ reply("systemStatus", statusForBundle(), 0);
1871
+ },
1872
+ setConnected,
1540
1873
  dispose() {
1541
1874
  unsubscribe();
1875
+ for (const t of streams.values()) clearInterval(t);
1876
+ streams.clear();
1542
1877
  for (const t of timers) clearTimeout(t);
1543
1878
  timers.clear();
1544
1879
  }
@@ -1598,15 +1933,18 @@ async function createDevHarness(opts) {
1598
1933
  iframe.setAttribute("title", "Condor Canvas bundle");
1599
1934
  iframe.setAttribute("style", "border:0;width:100%;height:100%;display:block;background:#fff");
1600
1935
  (opts.mount ?? document.body).appendChild(iframe);
1936
+ let confirmations = "ask";
1601
1937
  const shell = new BridgeShell({
1602
1938
  iframe,
1603
1939
  policy: CANVAS_POLICY,
1604
- onConfirmationRequest: opts.onConfirmationRequest ?? confirmInPage
1940
+ onConfirmationRequest: (req) => confirmations === "ask" ? (opts.onConfirmationRequest ?? confirmInPage)(req) : confirmations === "accept"
1605
1941
  });
1606
1942
  if (opts.devPanel !== false) {
1607
1943
  installDevPanel({ adapter, options: { startCollapsed: true } });
1608
1944
  }
1609
- await shell.bootstrapClientIframeWithHtml(opts.html);
1945
+ const deviceLike = "<script>try{delete Crypto.prototype.randomUUID}catch(e){}</script>";
1946
+ const html = /<head[^>]*>/i.test(opts.html) ? opts.html.replace(/<head[^>]*>/i, (m) => m + deviceLike) : deviceLike + opts.html;
1947
+ await shell.bootstrapClientIframeWithHtml(html);
1610
1948
  try {
1611
1949
  const result = await shell.verifySandbox();
1612
1950
  if (!result.ok) {
@@ -1631,6 +1969,15 @@ async function createDevHarness(opts) {
1631
1969
  emit(key, payload) {
1632
1970
  Bridge.mockEmit(key, payload);
1633
1971
  },
1972
+ setStatus(patch) {
1973
+ native.setStatus(patch);
1974
+ },
1975
+ setConnected(deviceId, value) {
1976
+ native.setConnected(deviceId, value);
1977
+ },
1978
+ setConfirmations(mode) {
1979
+ confirmations = mode;
1980
+ },
1634
1981
  pressBack() {
1635
1982
  native.pressBack();
1636
1983
  },
@@ -1652,6 +1999,7 @@ async function createDevHarness(opts) {
1652
1999
  createDevHarness,
1653
2000
  createSimulatedNative,
1654
2001
  formatLocalTimestamp,
2002
+ lightSensorsReading,
1655
2003
  parseLocalTimestamp,
1656
2004
  resolveScenario,
1657
2005
  sensorLogPage,