@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.js CHANGED
@@ -48,7 +48,7 @@ function telemetry(spec, now) {
48
48
  serialNo: spec.serialNo,
49
49
  attemptDate: now - 10 * 6e4,
50
50
  message: spec.syncError,
51
- messageCode: "SYNC_FAILED"
51
+ messageCode: "send_data_log_error"
52
52
  }
53
53
  ] : []
54
54
  };
@@ -132,6 +132,7 @@ function systemStatus(overrides = {}) {
132
132
  internet: true,
133
133
  permission: true,
134
134
  hasDevices: true,
135
+ hasDevicesKnown: true,
135
136
  missingOptionalPermissions: [],
136
137
  missingEssentialPermissions: [],
137
138
  deviceSyncError: {},
@@ -147,13 +148,15 @@ function lightAt(epochMs, serialNo) {
147
148
  const photopic = Math.round((daylight * 1800 + noise * 40) * 10) / 10;
148
149
  return { photopic, melanopic: Math.round(photopic * 0.62 * 10) / 10 };
149
150
  }
150
- function logAt(epochMs, serialNo, deviceId) {
151
- const { photopic, melanopic } = lightAt(epochMs, serialNo);
151
+ function logAt(epochMs, serialNo, deviceId, bodyPart, docked) {
152
+ const { photopic, melanopic } = docked ? { photopic: 0, melanopic: 0 } : lightAt(epochMs, serialNo);
152
153
  return {
153
154
  id: Math.floor(epochMs / 1e3) * 10 + serialNo % 10,
154
155
  log_time: formatLocalTimestamp(new Date(epochMs)),
155
156
  log_type: 1,
156
- body_part: "wrist",
157
+ body_part: bodyPart,
158
+ // The device's raw wear flag; the name misleads: 1 = worn, 0 = on the dock.
159
+ off_wrist: docked ? 0 : 1,
157
160
  temperature: 31.5,
158
161
  lux_photopic: photopic,
159
162
  lux_melanopic: melanopic,
@@ -161,6 +164,67 @@ function logAt(epochMs, serialNo, deviceId) {
161
164
  act_data_pim: 120
162
165
  };
163
166
  }
167
+ function spectrum(nm, photopic) {
168
+ const out = {};
169
+ for (const w of nm) {
170
+ const weight = Math.exp(-(((w - 555) / 120) ** 2));
171
+ out[`wavelength${w}`] = Math.round(photopic * weight * 0.01 * 1e4) / 1e4;
172
+ }
173
+ return out;
174
+ }
175
+ var GEN1_NM = [390, 415, 445, 480, 515, 555, 590, 630, 680, 750];
176
+ var GEN2_NM = [395, 426, 457, 488, 520, 551, 582, 613, 644, 675, 707, 738, 769, 800];
177
+ function lightSensorsReading(device, epochMs = Date.now(), withDeviceId = true) {
178
+ const { photopic, melanopic } = lightAt(epochMs, device.serialNo);
179
+ const raw = {
180
+ light_sensor_f1: 0.01,
181
+ light_sensor_f2: 0.02,
182
+ light_sensor_f3: 0.03,
183
+ light_sensor_f4: 0.04,
184
+ light_sensor_f5: 0.05,
185
+ light_sensor_f6: 0.04,
186
+ light_sensor_f7: 0.03,
187
+ light_sensor_f8: 0.02,
188
+ light_sensor_clear: Math.round(photopic * 2e-4 * 1e4) / 1e4,
189
+ light_sensor_nir: 0.01
190
+ };
191
+ const address = {
192
+ serialNo: device.serialNo,
193
+ ...withDeviceId ? { deviceId: device.deviceId } : {}
194
+ };
195
+ if (device.modelId === "actlumus_plus") {
196
+ return {
197
+ ...address,
198
+ type: "actlumus2",
199
+ data: {
200
+ ...raw,
201
+ ...spectrum(GEN2_NM, photopic),
202
+ time: epochMs,
203
+ photopic,
204
+ melanopic,
205
+ light_sensor_z: 9e-3,
206
+ light_sensor_y: 0.038,
207
+ light_sensor_x: 0.044,
208
+ light_sensor_fd: 0
209
+ }
210
+ };
211
+ }
212
+ return {
213
+ ...address,
214
+ type: "actlumus",
215
+ data: {
216
+ ...raw,
217
+ ...spectrum(GEN1_NM, photopic),
218
+ time: epochMs,
219
+ photopic,
220
+ melanopic,
221
+ light_sensor_z: null,
222
+ light_sensor_y: null,
223
+ light_sensor_x: null,
224
+ light_sensor_fd: null
225
+ }
226
+ };
227
+ }
164
228
  var HISTORY_DAYS = 14;
165
229
  var encodeCursor = (epochMs) => `mock:${epochMs}`;
166
230
  var decodeCursor = (cursor) => Number(cursor.slice("mock:".length));
@@ -175,7 +239,15 @@ function sensorLogPage(query, opts = {}) {
175
239
  return { requestId: query.requestId, success: false, error: "CONFLICTING_CURSORS" };
176
240
  }
177
241
  const newest = Math.floor(now / periodMs) * periodMs;
178
- const oldest = newest - HISTORY_DAYS * 864e5;
242
+ const history = opts.history ?? {};
243
+ const oldest = newest - (history.days ?? HISTORY_DAYS) * 864e5;
244
+ const dock = new Set(history.dockHours ?? [7]);
245
+ const gapHours = new Set(history.gapHours ?? []);
246
+ const gapDates = new Set(history.gapDates ?? []);
247
+ const present = (t) => {
248
+ const d = new Date(t);
249
+ return !gapHours.has(d.getHours()) && !gapDates.has(formatLocalTimestamp(d).slice(0, 10));
250
+ };
179
251
  const rangeStart = query.startTime ? parseLocalTimestamp(query.startTime) : oldest;
180
252
  const rangeEnd = query.endTime ? parseLocalTimestamp(query.endTime) - 1 : newest;
181
253
  if (Number.isNaN(rangeStart) || Number.isNaN(rangeEnd) || rangeStart > rangeEnd + 1) {
@@ -183,33 +255,41 @@ function sensorLogPage(query, opts = {}) {
183
255
  }
184
256
  const first = Math.max(oldest, Math.ceil(rangeStart / periodMs) * periodMs);
185
257
  const last = Math.min(newest, Math.floor(rangeEnd / periodMs) * periodMs);
186
- let from;
187
- let to;
258
+ const walk = (start, step, max) => {
259
+ const out = [];
260
+ for (let t = start; t >= first && t <= last && out.length < max; t += step) {
261
+ if (present(t)) out.push(t);
262
+ }
263
+ return out;
264
+ };
265
+ let slots;
188
266
  if (query.endingBefore) {
189
- to = Math.min(last, decodeCursor(query.endingBefore) - periodMs);
190
- from = Math.max(first, to - (limit - 1) * periodMs);
267
+ slots = walk(
268
+ Math.min(last, decodeCursor(query.endingBefore) - periodMs),
269
+ -periodMs,
270
+ limit
271
+ ).reverse();
191
272
  } else if (query.startingAfter) {
192
- from = Math.max(first, decodeCursor(query.startingAfter) + periodMs);
193
- to = Math.min(last, from + (limit - 1) * periodMs);
273
+ slots = walk(Math.max(first, decodeCursor(query.startingAfter) + periodMs), periodMs, limit);
194
274
  } else if (query.startTime) {
195
- from = first;
196
- to = Math.min(last, from + (limit - 1) * periodMs);
275
+ slots = walk(first, periodMs, limit);
197
276
  } else {
198
- to = last;
199
- from = Math.max(first, to - (limit - 1) * periodMs);
200
- }
201
- const logs = [];
202
- for (let t = from; t <= to; t += periodMs) {
203
- logs.push(logAt(t, query.serialNo, opts.deviceId ?? 0));
277
+ slots = walk(last, -periodMs, limit).reverse();
204
278
  }
279
+ const bodyPart = opts.bodyPart ?? "wrist";
280
+ const logs = slots.map(
281
+ (t) => logAt(t, query.serialNo, opts.deviceId ?? 0, bodyPart, dock.has(new Date(t).getHours()))
282
+ );
283
+ const from = slots[0] ?? 0;
284
+ const to = slots[slots.length - 1] ?? 0;
205
285
  return {
206
286
  requestId: query.requestId,
207
287
  success: true,
208
288
  logs,
209
289
  startingAfter: logs.length ? encodeCursor(to) : null,
210
290
  endingBefore: logs.length ? encodeCursor(from) : null,
211
- hasMore: logs.length > 0 && to < last,
212
- hasPrevious: logs.length > 0 && from > first
291
+ hasMore: logs.length > 0 && walk(to + periodMs, periodMs, 1).length > 0,
292
+ hasPrevious: logs.length > 0 && walk(from - periodMs, -periodMs, 1).length > 0
213
293
  };
214
294
  }
215
295
 
@@ -1203,6 +1283,19 @@ var BridgeShell = class {
1203
1283
  }
1204
1284
  };
1205
1285
 
1286
+ // src/version.ts
1287
+ function versionAtLeast(version, min) {
1288
+ if (!version) return false;
1289
+ const a = version.split(".").map((n) => Number.parseInt(n, 10) || 0);
1290
+ const b = min.split(".").map((n) => Number.parseInt(n, 10) || 0);
1291
+ for (let i = 0; i < 3; i++) {
1292
+ const x = a[i] ?? 0;
1293
+ const y = b[i] ?? 0;
1294
+ if (x !== y) return x > y;
1295
+ }
1296
+ return true;
1297
+ }
1298
+
1206
1299
  // src/dev/policy.ts
1207
1300
  var CANVAS_POLICY = {
1208
1301
  readOnlyKeys: [
@@ -1238,28 +1331,27 @@ var CANVAS_POLICY = {
1238
1331
  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';";
1239
1332
 
1240
1333
  // src/dev/scenarios.ts
1241
- var HARNESS_NATIVE_VERSION = "0.17.0";
1334
+ var HARNESS_NATIVE_VERSION = "0.18.0";
1242
1335
  var base = {
1243
- description: "Two devices, background service already running, realistic latency.",
1336
+ 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.",
1244
1337
  nativeVersion: HARNESS_NATIVE_VERSION,
1245
1338
  latencyMs: [80, 250],
1246
1339
  serviceStartMs: 0,
1247
- userDeviceSilent: false,
1248
1340
  logPeriodS: 60,
1249
1341
  systemStatus: systemStatus(),
1250
1342
  devices: () => twoDevices()
1251
1343
  };
1252
1344
  var SCENARIOS = {
1253
- default: base,
1345
+ default: { ...base, firstSessionAfterMs: 3e3 },
1254
1346
  "cold-start": {
1255
1347
  ...base,
1256
- 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.",
1348
+ 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.",
1257
1349
  serviceStartMs: 4e3
1258
1350
  },
1259
1351
  "no-devices": {
1260
1352
  ...base,
1261
- description: "Account with no linked device: userDevice never answers and systemStatus.hasDevices is false. Your bundle must show an empty state.",
1262
- userDeviceSilent: true,
1353
+ 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.",
1354
+ userDeviceError: "NO_DEVICES",
1263
1355
  systemStatus: systemStatus({ hasDevices: false })
1264
1356
  },
1265
1357
  "shared-serial": {
@@ -1269,9 +1361,8 @@ var SCENARIOS = {
1269
1361
  },
1270
1362
  offline: {
1271
1363
  ...base,
1272
- description: "No internet. systemStatus.internet is false and sensorLog fails; userDevice answers from the cache.",
1273
- sensorLogError: "Connection failed",
1274
- systemStatus: systemStatus({ internet: false })
1364
+ 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 }).",
1365
+ systemStatus: systemStatus({ internet: false, hasDevicesKnown: false })
1275
1366
  }
1276
1367
  };
1277
1368
  function resolveScenario(scenario) {
@@ -1290,6 +1381,7 @@ function resolveScenario(scenario) {
1290
1381
 
1291
1382
  // src/dev/harness.ts
1292
1383
  var DEV_HARNESS_MARKER = "__condorDevHarness__";
1384
+ var STEP_MS = 200;
1293
1385
  function createSimulatedNative(adapter, scenario, hooks = {}) {
1294
1386
  const now = hooks.now ?? Date.now;
1295
1387
  const startedAt = now();
@@ -1297,6 +1389,26 @@ function createSimulatedNative(adapter, scenario, hooks = {}) {
1297
1389
  const timers = /* @__PURE__ */ new Set();
1298
1390
  let status = { ...scenario.systemStatus };
1299
1391
  let canGoBack = false;
1392
+ const alwaysReplies = versionAtLeast(scenario.nativeVersion, "0.18.0");
1393
+ let pendingUserDevice = false;
1394
+ let sensorLogCount = 0;
1395
+ const roster = scenario.devices().devicesConfig;
1396
+ const connected = new Map(roster.map((d) => [d.deviceId, false]));
1397
+ const subscribed = /* @__PURE__ */ new Set();
1398
+ const realTimeOn = /* @__PURE__ */ new Set();
1399
+ const streams = /* @__PURE__ */ new Map();
1400
+ const byId = versionAtLeast(scenario.nativeVersion, "0.17.0");
1401
+ const pendingSync = /* @__PURE__ */ new Set();
1402
+ const pendingErase = /* @__PURE__ */ new Set();
1403
+ const running = /* @__PURE__ */ new Set();
1404
+ const sessionStart = /* @__PURE__ */ new Map();
1405
+ const idOf = (device) => byId && roster.filter((d) => d.serialNo === device.serialNo).length > 1 ? { deviceId: device.deviceId } : {};
1406
+ const findDevice = (args) => args.deviceId !== void 0 ? roster.find((d) => d.deviceId === args.deviceId) : roster.find((d) => d.serialNo === args.serialNo);
1407
+ const statusForBundle = () => {
1408
+ if (alwaysReplies) return status;
1409
+ const { hasDevicesKnown: _omitted, ...older } = status;
1410
+ return older;
1411
+ };
1300
1412
  const record2 = (entry) => {
1301
1413
  const full = { at: now(), ...entry };
1302
1414
  log.push(full);
@@ -1311,12 +1423,34 @@ function createSimulatedNative(adapter, scenario, hooks = {}) {
1311
1423
  const reply = (key, payload, delay = latency()) => {
1312
1424
  const timer = setTimeout(() => {
1313
1425
  timers.delete(timer);
1314
- record2({ kind: "reply", key, detail: `${key} \u2192 bundle` });
1426
+ if (typeof payload === "function") payload = payload();
1427
+ record2({ kind: "reply", key, detail: `${key} \u2192 bundle`, payload });
1315
1428
  adapter.triggerEmit(key, payload);
1316
1429
  }, delay);
1317
1430
  timers.add(timer);
1318
1431
  };
1319
1432
  const serviceUp = () => now() - startedAt >= scenario.serviceStartMs;
1433
+ const answerUserDevice = () => {
1434
+ if (scenario.userDeviceError) {
1435
+ if (!alwaysReplies) {
1436
+ record2({
1437
+ kind: "dropped",
1438
+ command: "userDevice",
1439
+ detail: `userDevice unanswered: the lookup failed (${scenario.userDeviceError}) and this app version replies only on success`
1440
+ });
1441
+ return;
1442
+ }
1443
+ reply("userDevice", {
1444
+ lastUpdate: now(),
1445
+ devicesConfig: [],
1446
+ devicesTelemetry: [],
1447
+ devicesUserConfig: [],
1448
+ error: scenario.userDeviceError
1449
+ });
1450
+ return;
1451
+ }
1452
+ reply("userDevice", scenario.devices());
1453
+ };
1320
1454
  const language = () => {
1321
1455
  const deviceLocale = typeof navigator !== "undefined" ? navigator.language : "en-US";
1322
1456
  const lang = deviceLocale.slice(0, 2);
@@ -1333,7 +1467,7 @@ function createSimulatedNative(adapter, scenario, hooks = {}) {
1333
1467
  if (command !== "handshake") {
1334
1468
  const shown = JSON.stringify(args);
1335
1469
  const detail = shown === "{}" ? command : `${command} ${shown.length > 160 ? `${shown.slice(0, 157)}...` : shown}`;
1336
- record2({ kind: "sent", command, detail });
1470
+ record2({ kind: "sent", command, detail, args });
1337
1471
  }
1338
1472
  switch (command) {
1339
1473
  case "handshake":
@@ -1345,8 +1479,8 @@ function createSimulatedNative(adapter, scenario, hooks = {}) {
1345
1479
  return;
1346
1480
  }
1347
1481
  reply("handshake-ack", { nativeVersion: scenario.nativeVersion, accepted: true });
1348
- reply("systemStatus", status, latency() + 20);
1349
- reply("language", language(), latency() + 30);
1482
+ reply("systemStatus", statusForBundle, latency() + 20);
1483
+ reply("language", language, latency() + 30);
1350
1484
  return;
1351
1485
  case "ping":
1352
1486
  if (!serviceUp()) {
@@ -1361,30 +1495,33 @@ function createSimulatedNative(adapter, scenario, hooks = {}) {
1361
1495
  return;
1362
1496
  case "userDevice":
1363
1497
  if (!serviceUp()) {
1498
+ pendingUserDevice = alwaysReplies;
1364
1499
  record2({
1365
1500
  kind: "dropped",
1366
1501
  command,
1367
- detail: "userDevice lost: the background service is not running yet. Ask again."
1368
- });
1369
- return;
1370
- }
1371
- if (scenario.userDeviceSilent) {
1372
- record2({
1373
- kind: "dropped",
1374
- command,
1375
- detail: "userDevice unanswered: the account has no linked device (the app replies only on success)"
1502
+ 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."
1376
1503
  });
1377
1504
  return;
1378
1505
  }
1379
- reply("userDevice", scenario.devices());
1506
+ answerUserDevice();
1380
1507
  return;
1381
1508
  case "sensorLog": {
1382
1509
  const query = args;
1383
- if (scenario.sensorLogError) {
1510
+ sensorLogCount += 1;
1511
+ if (scenario.sensorLogFailOnRequest === sensorLogCount) {
1384
1512
  reply("sensorLog", {
1385
1513
  requestId: query.requestId,
1386
1514
  success: false,
1387
- error: scenario.sensorLogError
1515
+ error: "Connection failed"
1516
+ });
1517
+ return;
1518
+ }
1519
+ const logError = scenario.sensorLogError ?? (status.internet === false ? "Connection failed" : void 0);
1520
+ if (logError) {
1521
+ reply("sensorLog", {
1522
+ requestId: query.requestId,
1523
+ success: false,
1524
+ error: logError
1388
1525
  });
1389
1526
  return;
1390
1527
  }
@@ -1403,12 +1540,29 @@ function createSimulatedNative(adapter, scenario, hooks = {}) {
1403
1540
  sensorLogPage(query, {
1404
1541
  now: now(),
1405
1542
  periodS: scenario.logPeriodS,
1406
- deviceId: device?.deviceId
1543
+ deviceId: device?.deviceId,
1544
+ bodyPart: device?.deviceBodyPart ?? "wrist",
1545
+ history: scenario.history
1407
1546
  })
1408
1547
  );
1409
1548
  return;
1410
1549
  }
1411
- case "realTime":
1550
+ case "realTime": {
1551
+ const device = findDevice(args);
1552
+ if (device) {
1553
+ if (args.value === true) realTimeOn.add(device.deviceId);
1554
+ else realTimeOn.delete(device.deviceId);
1555
+ syncStream(device.deviceId);
1556
+ if (args.value !== true && connected.get(device.deviceId) && !running.has(device.deviceId)) {
1557
+ endSession(device.deviceId, latency());
1558
+ }
1559
+ if (args.value === true && !connected.get(device.deviceId)) {
1560
+ record2({
1561
+ kind: "info",
1562
+ detail: `realTime on for device ${device.deviceId}, which is between BLE sessions: readings start when it connects`
1563
+ });
1564
+ }
1565
+ }
1412
1566
  reply("realTime", {
1413
1567
  serialNo: args.serialNo,
1414
1568
  ...args.deviceId !== void 0 ? { deviceId: args.deviceId } : {},
@@ -1416,23 +1570,37 @@ function createSimulatedNative(adapter, scenario, hooks = {}) {
1416
1570
  value: args.value === true
1417
1571
  });
1418
1572
  return;
1573
+ }
1419
1574
  case "syncTime":
1420
1575
  case "deleteMemory":
1576
+ {
1577
+ const target = findDevice(args);
1578
+ if (target) (command === "syncTime" ? pendingSync : pendingErase).add(target.deviceId);
1579
+ }
1580
+ record2({
1581
+ kind: "info",
1582
+ 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"
1583
+ });
1421
1584
  reply(command, {
1422
1585
  serialNo: args.serialNo,
1423
1586
  ...args.deviceId !== void 0 ? { deviceId: args.deviceId } : {},
1424
1587
  success: true
1425
1588
  });
1426
1589
  return;
1427
- case "deviceConnection":
1428
- if (args.type === "add") {
1590
+ case "deviceConnection": {
1591
+ const device = findDevice(args);
1592
+ if (!device) {
1429
1593
  reply("deviceConnection", {
1430
1594
  serialNo: args.serialNo,
1431
1595
  ...args.deviceId !== void 0 ? { deviceId: args.deviceId } : {},
1432
- isConnect: true
1596
+ isConnect: false
1433
1597
  });
1598
+ return;
1434
1599
  }
1600
+ if (args.type === "add") subscribed.add(device.deviceId);
1601
+ else if (args.type === "remove") subscribed.delete(device.deviceId);
1435
1602
  return;
1603
+ }
1436
1604
  case "language":
1437
1605
  reply("language", language());
1438
1606
  return;
@@ -1443,7 +1611,7 @@ function createSimulatedNative(adapter, scenario, hooks = {}) {
1443
1611
  missingEssentialPermissions: status.missingEssentialPermissions.filter((p) => p !== name),
1444
1612
  missingOptionalPermissions: status.missingOptionalPermissions.filter((p) => p !== name)
1445
1613
  };
1446
- reply("systemStatus", status, latency() + 400);
1614
+ reply("systemStatus", statusForBundle, latency() + 400);
1447
1615
  return;
1448
1616
  }
1449
1617
  case "jsError":
@@ -1467,11 +1635,170 @@ function createSimulatedNative(adapter, scenario, hooks = {}) {
1467
1635
  return;
1468
1636
  }
1469
1637
  };
1638
+ function emitConnection(deviceId) {
1639
+ if (!subscribed.has(deviceId)) return;
1640
+ const device = roster.find((d) => d.deviceId === deviceId);
1641
+ if (!device) return;
1642
+ reply("deviceConnection", {
1643
+ serialNo: device.serialNo,
1644
+ ...idOf(device),
1645
+ isConnect: connected.get(deviceId) === true
1646
+ });
1647
+ }
1648
+ function syncStream(deviceId) {
1649
+ const want = realTimeOn.has(deviceId) && connected.get(deviceId) === true;
1650
+ const running2 = streams.get(deviceId);
1651
+ if (want && !running2) {
1652
+ const device = roster.find((d) => d.deviceId === deviceId);
1653
+ if (!device) return;
1654
+ streams.set(
1655
+ deviceId,
1656
+ setInterval(() => {
1657
+ adapter.triggerEmit(
1658
+ "deviceLightSensors",
1659
+ lightSensorsReading(device, now(), "deviceId" in idOf(device))
1660
+ );
1661
+ }, 1e3)
1662
+ );
1663
+ } else if (!want && running2) {
1664
+ clearInterval(running2);
1665
+ streams.delete(deviceId);
1666
+ }
1667
+ }
1668
+ function setConnected(deviceId, value) {
1669
+ if (!connected.has(deviceId)) {
1670
+ record2({ kind: "info", detail: `setConnected: no device ${deviceId} in this scenario` });
1671
+ return;
1672
+ }
1673
+ if (connected.get(deviceId) === value) return;
1674
+ connected.set(deviceId, value);
1675
+ record2({
1676
+ kind: "info",
1677
+ detail: `device ${deviceId}: BLE session ${value ? "started" : "ended"}`
1678
+ });
1679
+ emitConnection(deviceId);
1680
+ syncStream(deviceId);
1681
+ const device = roster.find((d) => d.deviceId === deviceId);
1682
+ if (device) runSession(device, value);
1683
+ }
1684
+ function setSyncError(device, text, delay) {
1685
+ const timer = setTimeout(() => {
1686
+ timers.delete(timer);
1687
+ const byId2 = { ...status.deviceSyncErrorById ?? {} };
1688
+ const bySerial = { ...status.deviceSyncError };
1689
+ const shared = roster.filter((d) => d.serialNo === device.serialNo).length > 1;
1690
+ if (text === null) {
1691
+ if (!(device.deviceId in byId2)) return;
1692
+ delete byId2[device.deviceId];
1693
+ if (!shared) delete bySerial[device.serialNo];
1694
+ } else {
1695
+ byId2[device.deviceId] = text;
1696
+ if (!shared) bySerial[device.serialNo] = text;
1697
+ }
1698
+ status = { ...status, deviceSyncErrorById: byId2, deviceSyncError: bySerial };
1699
+ reply("systemStatus", statusForBundle, 0);
1700
+ }, delay);
1701
+ timers.add(timer);
1702
+ }
1703
+ function runSession(device, start) {
1704
+ const note = (message, messageCode, isError = false) => ({
1705
+ serialNo: device.serialNo,
1706
+ ...idOf(device),
1707
+ isConnect: !isError,
1708
+ message,
1709
+ messageCode,
1710
+ isError
1711
+ });
1712
+ if (!start) {
1713
+ reply("deviceNotification", { ...note("Device disconnected", null), isConnect: false });
1714
+ return;
1715
+ }
1716
+ const id = device.deviceId;
1717
+ const steps = [
1718
+ ["[Start] Enable BLE periodic", "enable_ble_periodic_notification", "enable_ble_periodic"],
1719
+ ["[Not needed] Enable BLE periodic", "enable_ble_periodic_not_needed_notification", ""],
1720
+ ["[Start] Set BLE period", "set_ble_period_notification", "set_ble_period"],
1721
+ ["[Not needed] Set BLE period", "set_ble_period_not_needed_notification", ""],
1722
+ ["[Start] Set sync time", "set_sync_time_notification", "set_sync_time"],
1723
+ ...pendingSync.has(id) ? [] : [["[Not needed] Set sync time", "set_sync_time_not_needed_notification", ""]],
1724
+ ["[Start] Get data log", "get_data_log_notification", "get_data_log"],
1725
+ ["[Start] Send data log", "send_data_log_notification", "send_data_log"],
1726
+ [
1727
+ "[Success] Set last data log sent to server",
1728
+ "set_last_data_log_sent_to_server_notification",
1729
+ ""
1730
+ ],
1731
+ ["[Start] Get system log", "get_system_log_notification", "get_system_log"],
1732
+ ["[Start] Send system log", "send_system_log_notification", "send_system_log"],
1733
+ ["[Start] Get full charge log", "get_full_charge_log_notification", "get_full_charge_log"],
1734
+ ["[Start] Send full charge log", "send_full_charge_log_notification", "send_full_charge_log"],
1735
+ ["[Start] Set erase memory", "set_erase_memory_notification", "set_erase_memory"],
1736
+ ...pendingErase.has(id) ? [["[Start] Erase memory", "erase_memory_notification", ""]] : [],
1737
+ ["[Start] Get device telemetry", "get_device_telemetry_notification", "get_device_telemetry"],
1738
+ ["[Start] Save device telemetry", "save_device_telemetry_notification", ""]
1739
+ ];
1740
+ const failAt = scenario.sessionFailAt ?? (status.internet === false ? "send_data_log" : void 0);
1741
+ running.add(id);
1742
+ sessionStart.set(id, now());
1743
+ let delay = latency();
1744
+ reply("deviceNotification", note("Device connected", null), delay);
1745
+ for (const [message, code, stage] of steps) {
1746
+ delay += STEP_MS;
1747
+ reply("deviceNotification", note(message, code), delay);
1748
+ if (stage && failAt === stage) {
1749
+ delay += STEP_MS;
1750
+ const what = message.replace(/^\[\w+\] /, "");
1751
+ reply("deviceNotification", note(`[Error] ${what}`, `${stage}_error`, true), delay);
1752
+ setSyncError(device, `Sync failed (${stage}_error)`, delay + 20);
1753
+ endSession(id, delay + STEP_MS);
1754
+ return;
1755
+ }
1756
+ if (stage === "set_sync_time") pendingSync.delete(id);
1757
+ }
1758
+ delay += STEP_MS;
1759
+ reply("deviceNotification", note("[Done] Pipeline", "pipeline_done_notification"), delay);
1760
+ setSyncError(device, null, delay + 20);
1761
+ pendingErase.delete(id);
1762
+ endSession(id, delay + STEP_MS);
1763
+ }
1764
+ function endSession(deviceId, delay) {
1765
+ const timer = setTimeout(() => {
1766
+ timers.delete(timer);
1767
+ running.delete(deviceId);
1768
+ if (!realTimeOn.has(deviceId)) {
1769
+ setConnected(deviceId, false);
1770
+ return;
1771
+ }
1772
+ const startedAt2 = sessionStart.get(deviceId) ?? now();
1773
+ const hold = setTimeout(
1774
+ () => {
1775
+ timers.delete(hold);
1776
+ if (connected.get(deviceId) && !running.has(deviceId)) setConnected(deviceId, false);
1777
+ },
1778
+ Math.max(0, startedAt2 + (scenario.realTimeSessionMs ?? 18e4) - now())
1779
+ );
1780
+ timers.add(hold);
1781
+ }, delay);
1782
+ timers.add(timer);
1783
+ }
1470
1784
  const unsubscribe = adapter.onCallHandler(handle);
1785
+ const first = roster[0];
1786
+ if (first && typeof scenario.firstSessionAfterMs === "number") {
1787
+ const timer = setTimeout(() => {
1788
+ timers.delete(timer);
1789
+ setConnected(first.deviceId, true);
1790
+ }, scenario.firstSessionAfterMs);
1791
+ timers.add(timer);
1792
+ }
1471
1793
  if (scenario.serviceStartMs > 0) {
1472
1794
  const timer = setTimeout(() => {
1473
1795
  timers.delete(timer);
1474
1796
  record2({ kind: "info", detail: "background service is up: ping and userDevice now answer" });
1797
+ if (pendingUserDevice) {
1798
+ pendingUserDevice = false;
1799
+ record2({ kind: "info", detail: "the app re-sent the pending userDevice" });
1800
+ answerUserDevice();
1801
+ }
1475
1802
  }, scenario.serviceStartMs);
1476
1803
  timers.add(timer);
1477
1804
  }
@@ -1487,8 +1814,15 @@ function createSimulatedNative(adapter, scenario, hooks = {}) {
1487
1814
  }
1488
1815
  reply("navigationBack", {}, 0);
1489
1816
  },
1817
+ setStatus(patch) {
1818
+ status = { ...status, ...patch };
1819
+ reply("systemStatus", statusForBundle(), 0);
1820
+ },
1821
+ setConnected,
1490
1822
  dispose() {
1491
1823
  unsubscribe();
1824
+ for (const t of streams.values()) clearInterval(t);
1825
+ streams.clear();
1492
1826
  for (const t of timers) clearTimeout(t);
1493
1827
  timers.clear();
1494
1828
  }
@@ -1548,15 +1882,18 @@ async function createDevHarness(opts) {
1548
1882
  iframe.setAttribute("title", "Condor Canvas bundle");
1549
1883
  iframe.setAttribute("style", "border:0;width:100%;height:100%;display:block;background:#fff");
1550
1884
  (opts.mount ?? document.body).appendChild(iframe);
1885
+ let confirmations = "ask";
1551
1886
  const shell = new BridgeShell({
1552
1887
  iframe,
1553
1888
  policy: CANVAS_POLICY,
1554
- onConfirmationRequest: opts.onConfirmationRequest ?? confirmInPage
1889
+ onConfirmationRequest: (req) => confirmations === "ask" ? (opts.onConfirmationRequest ?? confirmInPage)(req) : confirmations === "accept"
1555
1890
  });
1556
1891
  if (opts.devPanel !== false) {
1557
1892
  installDevPanel({ adapter, options: { startCollapsed: true } });
1558
1893
  }
1559
- await shell.bootstrapClientIframeWithHtml(opts.html);
1894
+ const deviceLike = "<script>try{delete Crypto.prototype.randomUUID}catch(e){}</script>";
1895
+ const html = /<head[^>]*>/i.test(opts.html) ? opts.html.replace(/<head[^>]*>/i, (m) => m + deviceLike) : deviceLike + opts.html;
1896
+ await shell.bootstrapClientIframeWithHtml(html);
1560
1897
  try {
1561
1898
  const result = await shell.verifySandbox();
1562
1899
  if (!result.ok) {
@@ -1581,6 +1918,15 @@ async function createDevHarness(opts) {
1581
1918
  emit(key, payload) {
1582
1919
  Bridge.mockEmit(key, payload);
1583
1920
  },
1921
+ setStatus(patch) {
1922
+ native.setStatus(patch);
1923
+ },
1924
+ setConnected(deviceId, value) {
1925
+ native.setConnected(deviceId, value);
1926
+ },
1927
+ setConfirmations(mode) {
1928
+ confirmations = mode;
1929
+ },
1584
1930
  pressBack() {
1585
1931
  native.pressBack();
1586
1932
  },
@@ -1601,6 +1947,7 @@ export {
1601
1947
  createDevHarness,
1602
1948
  createSimulatedNative,
1603
1949
  formatLocalTimestamp,
1950
+ lightSensorsReading,
1604
1951
  parseLocalTimestamp,
1605
1952
  resolveScenario,
1606
1953
  sensorLogPage,