@mentra/engine 3.2.0-dev.261 → 3.2.0-dev.262

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (33) hide show
  1. package/build/generated/releaseMetadata.js +5 -5
  2. package/build/generated/releaseMetadata.js.map +1 -1
  3. package/build/services/AcsMeetingService.d.ts +25 -1
  4. package/build/services/AcsMeetingService.d.ts.map +1 -1
  5. package/build/services/AcsMeetingService.js +41 -3
  6. package/build/services/AcsMeetingService.js.map +1 -1
  7. package/build/services/LocalMiniappRuntime.d.ts +32 -3
  8. package/build/services/LocalMiniappRuntime.d.ts.map +1 -1
  9. package/build/services/LocalMiniappRuntime.js +284 -30
  10. package/build/services/LocalMiniappRuntime.js.map +1 -1
  11. package/build/services/MiniappLiveness.d.ts +11 -0
  12. package/build/services/MiniappLiveness.d.ts.map +1 -1
  13. package/build/services/MiniappLiveness.js +11 -0
  14. package/build/services/MiniappLiveness.js.map +1 -1
  15. package/build/services/PhoneStreamCoordinator.d.ts +13 -1
  16. package/build/services/PhoneStreamCoordinator.d.ts.map +1 -1
  17. package/build/services/PhoneStreamCoordinator.js +27 -4
  18. package/build/services/PhoneStreamCoordinator.js.map +1 -1
  19. package/build/services/SoftapCallTransport.d.ts +87 -7
  20. package/build/services/SoftapCallTransport.d.ts.map +1 -1
  21. package/build/services/SoftapCallTransport.js +229 -28
  22. package/build/services/SoftapCallTransport.js.map +1 -1
  23. package/build/utils/miniappGlobals.d.ts.map +1 -1
  24. package/build/utils/miniappGlobals.js +13 -11
  25. package/build/utils/miniappGlobals.js.map +1 -1
  26. package/package.json +8 -8
  27. package/src/generated/releaseMetadata.ts +5 -5
  28. package/src/services/AcsMeetingService.ts +51 -3
  29. package/src/services/LocalMiniappRuntime.ts +296 -30
  30. package/src/services/MiniappLiveness.ts +18 -0
  31. package/src/services/PhoneStreamCoordinator.ts +27 -5
  32. package/src/services/SoftapCallTransport.ts +274 -32
  33. package/src/utils/miniappGlobals.ts +13 -11
@@ -22,6 +22,13 @@
22
22
  import { softapTrace, softapTraceFailure, beginSoftapTrace, resetSoftapTrace } from "../utils/softapTrace";
23
23
  /** Steps in order. Also the teardown order, reversed. */
24
24
  export const SOFTAP_STEPS = ["hotspot", "scopedJoin", "acsJoin", "publish", "live"];
25
+ /**
26
+ * SoftAP loss is a media outage, not call termination. The wearer has this long to
27
+ * come back in range before the host should give up rebuilding the hop.
28
+ */
29
+ export const RETURN_DEADLINE_MS = 60_000;
30
+ /** Budget to re-arm hotspot, scoped join, ingest, and publish after the glasses return. */
31
+ export const REARM_BUDGET_MS = 45_000;
25
32
  /** A failure, named by the step that produced it so the UI and the logs agree on the cause. */
26
33
  export class SoftapCallError extends Error {
27
34
  step;
@@ -94,6 +101,16 @@ function isScopedJoinUnavailable(error) {
94
101
  const message = error instanceof Error ? error.message : String(error);
95
102
  return /SOFTAP_UNAVAILABLE|ScopedNetworkError\$Unavailable|SSID not in scan, Wi-Fi off, or the system join prompt/i.test(message);
96
103
  }
104
+ /**
105
+ * The glasses never answered, as opposed to answering "disabled".
106
+ *
107
+ * A timeout is not evidence the command was lost: BLE can redeliver it after the phone has given
108
+ * up, so the AP may be coming up at the very moment the retry would ask for it to go down.
109
+ */
110
+ function isHotspotAnswerTimeout(error) {
111
+ const message = error instanceof Error ? error.message : String(error);
112
+ return /timed out waiting for glasses response|glasses did not answer/i.test(message);
113
+ }
97
114
  function freshSteps() {
98
115
  return SOFTAP_STEPS.map((step) => ({ step, status: "pending" }));
99
116
  }
@@ -187,12 +204,40 @@ export class SoftapCallTransport {
187
204
  republishing = null;
188
205
  /** Bumped by `stop` so an in-flight republish cannot start_stream after Leave. */
189
206
  republishGeneration = 0;
207
+ /**
208
+ * Media hop generation. Distinct from [generation]: ACS join is preserved across recoveries,
209
+ * but hotspot, scoped network, ingest, and publisher belong to this number.
210
+ */
211
+ mediaGeneration = 0;
212
+ /** Why the current recover was requested. Cleared when the hop is live again. */
213
+ recoveryReason;
214
+ /** In-flight [recover]. Concurrent recoveries share one rebuild. */
215
+ recovering = null;
216
+ /** A [stop] without preserveMeeting landed during recover; recover must not rebuild. */
217
+ recoveryAbort = false;
218
+ /** Skip `acsJoin` undo so the ACS session survives a media-only teardown. */
219
+ meetingPreserved = false;
220
+ /**
221
+ * Identity of the publisher the most recent `startPublishing` created.
222
+ * A cancelled republish may only `stopPublishing` if it still owns this token.
223
+ */
224
+ publisherToken = null;
190
225
  constructor(deps) {
191
226
  this.deps = deps;
192
227
  }
193
228
  currentPhase() {
194
229
  return this.phase;
195
230
  }
231
+ currentMediaGeneration() {
232
+ return this.mediaGeneration;
233
+ }
234
+ recoveryState() {
235
+ return {
236
+ phase: this.phase,
237
+ mediaGeneration: this.mediaGeneration,
238
+ ...(this.recoveryReason ? { reason: this.recoveryReason } : {}),
239
+ };
240
+ }
196
241
  /**
197
242
  * The glasses camera died after this call was already live. Re-issue `start_stream` at the
198
243
  * existing ingest URL — do not rebind the WHIP listener, or the glasses POST to a dead port.
@@ -212,11 +257,15 @@ export class SoftapCallTransport {
212
257
  if (this.republishing)
213
258
  return this.republishing;
214
259
  const generation = this.republishGeneration;
215
- this.republishing = this.runRepublish(reason, generation).finally(() => {
216
- if (this.republishGeneration === generation)
260
+ let run;
261
+ run = this.runRepublish(reason, generation).finally(() => {
262
+ // Clear when *this* run still owns the field, even if stop() bumped the generation.
263
+ // Gating on generation left a settled promise stranded and the next republish was a no-op.
264
+ if (this.republishing === run)
217
265
  this.republishing = null;
218
266
  });
219
- return this.republishing;
267
+ this.republishing = run;
268
+ return run;
220
269
  }
221
270
  async runRepublish(reason, generation) {
222
271
  const ingestUrl = this.ingestUrl;
@@ -230,13 +279,19 @@ export class SoftapCallTransport {
230
279
  this.phase === "live" &&
231
280
  this.ingestUrl === ingestUrl) {
232
281
  attempt += 1;
282
+ let startedPublisher = false;
283
+ const token = {};
233
284
  try {
234
285
  await this.deps.stopPublishing();
235
286
  if (generation !== this.republishGeneration || this.terminating)
236
287
  return;
237
- await this.deps.startPublishing({ ingestUrl, traceId: this.traceId });
288
+ await this.startPublishingOwned(token, { ingestUrl, traceId: this.traceId });
289
+ startedPublisher = true;
238
290
  if (generation !== this.republishGeneration || this.terminating) {
239
- await this.deps.stopPublishing().catch(() => undefined);
291
+ // Only stop a publisher this run started, and only if a successor has not taken it.
292
+ if (startedPublisher && this.publisherToken === token) {
293
+ await this.deps.stopPublishing().catch(() => undefined);
294
+ }
240
295
  return;
241
296
  }
242
297
  softapTrace("glasses_republish_sent", { attempt, ingestUrl });
@@ -262,6 +317,88 @@ export class SoftapCallTransport {
262
317
  }
263
318
  }
264
319
  }
320
+ /**
321
+ * Rebuild the glasses→phone hop after SoftAP loss. ACS, mute intent, and media endpoints stay.
322
+ *
323
+ * Phase flips to `recovering` before any await so [shouldRepublish] stands down immediately —
324
+ * not after a BLE wait that could still issue `start_stream` at the dying hop.
325
+ */
326
+ recover(reason, options = {}) {
327
+ if (this.recovering)
328
+ return this.recovering;
329
+ if (this.phase !== "live") {
330
+ return Promise.reject(new SoftapCallError("live", "NOT_RECOVERABLE", `Cannot recover a SoftAP call that is ${this.phase}`));
331
+ }
332
+ this.phase = "recovering";
333
+ this.recoveryReason = reason;
334
+ this.recoveryAbort = false;
335
+ this.mediaGeneration++;
336
+ this.emitProgress();
337
+ softapTrace("softap_media_recover", { reason, mediaGeneration: this.mediaGeneration });
338
+ let run;
339
+ run = this.runRecover(options).finally(() => {
340
+ if (this.recovering === run)
341
+ this.recovering = null;
342
+ });
343
+ this.recovering = run;
344
+ return run;
345
+ }
346
+ async runRecover(options) {
347
+ const onProgress = this.onProgress;
348
+ // Wait for BLE *before* `set_hotspot_state false`. Doing stop first while the glasses are
349
+ // out of range queues the off command; when BLE returns it fires into a still-up hotspot
350
+ // and races the media-only start.
351
+ if (options.wait)
352
+ await options.wait();
353
+ if (this.recoveryAbort) {
354
+ throw new SoftapCallError("hotspot", "CANCELLED", "SoftAP recovery was cancelled");
355
+ }
356
+ await this.stop({ preserveMeeting: true, keepProgress: true });
357
+ if (this.recoveryAbort) {
358
+ throw new SoftapCallError("hotspot", "CANCELLED", "SoftAP recovery was cancelled");
359
+ }
360
+ // The return deadline bounds [options.wait]; the rebuild gets its own budget from the moment
361
+ // the glasses are back. Charging the rebuild for the time the wearer spent away left a return
362
+ // at 48s with 12s to re-arm a hotspot that takes ~30s, and the call was dropped as it worked.
363
+ const rearmMs = REARM_BUDGET_MS;
364
+ let budgetTimer;
365
+ const budget = new Promise((_, reject) => {
366
+ budgetTimer = setTimeout(() => {
367
+ void this.stop({ preserveMeeting: true }).catch(() => undefined);
368
+ reject(new SoftapCallError("hotspot", "REARM_BUDGET", "SoftAP media rebuild exceeded its budget"));
369
+ }, rearmMs);
370
+ });
371
+ try {
372
+ await Promise.race([this.start({ mediaOnly: true, onProgress }), budget]);
373
+ }
374
+ finally {
375
+ if (budgetTimer !== undefined)
376
+ clearTimeout(budgetTimer);
377
+ }
378
+ this.completed = [...SOFTAP_STEPS];
379
+ this.recoveryReason = undefined;
380
+ this.emitProgress();
381
+ }
382
+ /** Issue `start_stream` and take publisher ownership so a stale republish cannot stop a successor. */
383
+ async startPublishingOwned(token, args, report) {
384
+ const mediaGeneration = this.mediaGeneration;
385
+ const stamped = report
386
+ ? (detail) => {
387
+ if (mediaGeneration !== this.mediaGeneration) {
388
+ softapTrace("softap_stale_callback", {
389
+ source: "startPublishing",
390
+ mediaGeneration,
391
+ current: this.mediaGeneration,
392
+ detail,
393
+ });
394
+ return;
395
+ }
396
+ report(detail);
397
+ }
398
+ : undefined;
399
+ await this.deps.startPublishing({ ...args, mediaGeneration }, stamped);
400
+ this.publisherToken = token;
401
+ }
265
402
  /**
266
403
  * True once a teardown has been decided. Anything that would otherwise report a failure — a lost
267
404
  * hotspot, a dropped ACS call — must check this first: after the wearer asks to leave, those are
@@ -277,6 +414,7 @@ export class SoftapCallTransport {
277
414
  phase: this.phase,
278
415
  steps: this.steps.map((step) => ({ ...step })),
279
416
  elapsedMs: this.startedAt ? Date.now() - this.startedAt : 0,
417
+ mediaGeneration: this.mediaGeneration,
280
418
  };
281
419
  }
282
420
  emitProgress() {
@@ -317,11 +455,10 @@ export class SoftapCallTransport {
317
455
  return this.ingestUrl;
318
456
  }
319
457
  /**
320
- * Steps whose undo threw during the last teardown, so the caller can refuse the next call.
458
+ * Steps whose undo threw during the last teardown.
321
459
  *
322
- * Teardown deliberately swallows these to keep unwinding but a hotspot that would not turn off
323
- * is exactly the state the next call cannot be built on, and starting anyway is what produced
324
- * "Cannot start glasses hotspot" followed by a scoped join that never found the SSID.
460
+ * Teardown swallows these so unwind can finish. The host refuses the next join for ingest /
461
+ * scoped-network leaks; a hotspot that missed its off-ack is leftover ON and is raised again.
325
462
  */
326
463
  lastTeardownFailures() {
327
464
  // Logged on read rather than only on write, because this is the moment the list turns into a
@@ -379,17 +516,22 @@ export class SoftapCallTransport {
379
516
  softapTraceFailure("softap_call_refused", { reason: "cancelled before start" });
380
517
  throw new SoftapCallError("hotspot", "CANCELLED", "SoftAP call was cancelled before it started");
381
518
  }
382
- if (this.phase !== "idle" && this.phase !== "failed") {
519
+ const mediaOnly = options.mediaOnly === true;
520
+ const allowed = this.phase === "idle" || this.phase === "failed" || (mediaOnly && this.phase === "recovering");
521
+ if (!allowed) {
383
522
  softapTraceFailure("softap_call_refused", { reason: "already active", phase: this.phase });
384
523
  throw new SoftapCallError("hotspot", "ALREADY_ACTIVE", `A SoftAP call is already ${this.phase}`);
385
524
  }
386
525
  this.startedEver = true;
387
526
  const generation = ++this.generation;
388
- this.phase = "starting";
527
+ if (mediaOnly)
528
+ this.mediaGeneration++;
529
+ this.phase = mediaOnly ? "recovering" : "starting";
389
530
  this.terminating = false;
390
531
  this.teardownMode = "leave";
391
532
  this.endFailure = null;
392
- this.completed = [];
533
+ this.meetingPreserved = mediaOnly;
534
+ this.completed = mediaOnly ? this.completed.filter((step) => step === "acsJoin") : [];
393
535
  this.ingestUrl = null;
394
536
  this.hotspot = null;
395
537
  this.teardownFailures = [];
@@ -397,11 +539,16 @@ export class SoftapCallTransport {
397
539
  this.stepStartedAt.clear();
398
540
  this.startedAt = Date.now();
399
541
  this.onProgress = options.onProgress;
400
- const traceId = beginSoftapTrace(options.traceId);
542
+ // Media-only rebuilds mint a fresh trace id: the previous hop is gone and its id must not
543
+ // be reused, even if the caller still holds it.
544
+ const traceId = beginSoftapTrace(mediaOnly ? undefined : options.traceId);
401
545
  this.traceId = traceId;
402
- softapTrace("softap_call_start", { traceId });
546
+ softapTrace("softap_call_start", { traceId, mediaOnly, mediaGeneration: this.mediaGeneration });
403
547
  this.emitProgress();
404
548
  try {
549
+ if (mediaOnly && !this.deps.rebindIngest) {
550
+ throw new SoftapCallError("acsJoin", "ACS_JOIN_FAILED", "rebindIngest is required for a media-only rebuild");
551
+ }
405
552
  await this.preflightWifi();
406
553
  await this.step(generation, "hotspot", "HOTSPOT_FAILED", async (report) => {
407
554
  report("Asking the glasses to turn on their hotspot");
@@ -428,6 +575,17 @@ export class SoftapCallTransport {
428
575
  report(`Phone is ${bindAddress} on ${hotspot.ssid}`);
429
576
  });
430
577
  await this.step(generation, "acsJoin", "ACS_JOIN_FAILED", async (report) => {
578
+ if (mediaOnly) {
579
+ report(bindAddress ? `Rebinding the video receiver on ${bindAddress}` : "Rebinding the video receiver");
580
+ const { ingestUrl } = await this.deps.rebindIngest(report);
581
+ if (!ingestUrl) {
582
+ throw new Error("the meeting reported no ingest URL");
583
+ }
584
+ this.ingestUrl = ingestUrl;
585
+ softapTrace("acs_receiver_rebound", { ingestUrl });
586
+ report(`Receiver ready at ${ingestUrl}`);
587
+ return;
588
+ }
431
589
  report(bindAddress ? `Opening video receiver on ${bindAddress}, then joining Teams` : "Joining Teams");
432
590
  const { ingestUrl } = await this.deps.joinMeeting({
433
591
  ssid: hotspot.ssid,
@@ -446,13 +604,13 @@ export class SoftapCallTransport {
446
604
  const ingestUrl = this.requireIngestUrl();
447
605
  await this.step(generation, "publish", "PUBLISH_FAILED", async (report) => {
448
606
  report("Telling the glasses to start the camera and publish to the phone");
449
- await this.deps.startPublishing({ ingestUrl, traceId }, report);
607
+ await this.startPublishingOwned({}, { ingestUrl, traceId }, report);
450
608
  softapTrace("glasses_publishing", { ingestUrl });
451
609
  report("Glasses camera is streaming to the phone");
452
610
  });
453
611
  await this.step(generation, "live", "NO_FIRST_FRAME", async (report) => {
454
612
  report("Waiting for the first glasses video frame on this phone");
455
- await this.deps.awaitFirstFrame(report);
613
+ await this.deps.awaitFirstFrame(report, mediaOnly ? { fresh: true } : undefined);
456
614
  softapTrace("first_glasses_frame_received");
457
615
  report("Glasses video is reaching this phone");
458
616
  });
@@ -463,15 +621,20 @@ export class SoftapCallTransport {
463
621
  softapTraceFailure("softap_call_abandoned_at_live", { generation, current: this.generation });
464
622
  return;
465
623
  }
624
+ if (mediaOnly)
625
+ this.completed = [...SOFTAP_STEPS];
466
626
  this.phase = "live";
627
+ this.recoveryReason = undefined;
467
628
  softapTrace("softap_call_live");
468
629
  this.emitProgress();
469
630
  }
470
631
  catch (error) {
471
632
  // Unwind before rethrowing. A caller that sees a rejection is entitled to assume nothing was
472
633
  // left running, and a hotspot left up is both a battery cost and a second call's failure.
473
- await this.stop({ keepProgress: true });
474
- this.phase = "failed";
634
+ // Media-only failures keep ACS; a user Leave during recover does not (`recoveryAbort`).
635
+ await this.stop({ keepProgress: true, preserveMeeting: mediaOnly && !this.recoveryAbort });
636
+ if (!(mediaOnly && this.recoveryAbort))
637
+ this.phase = "failed";
475
638
  this.emitProgress();
476
639
  throw error;
477
640
  }
@@ -496,12 +659,18 @@ export class SoftapCallTransport {
496
659
  this.republishGeneration++;
497
660
  if (options.mode)
498
661
  this.teardownMode = options.mode;
662
+ if (!options.preserveMeeting)
663
+ this.recoveryAbort = true;
499
664
  if (this.stopping) {
500
- softapTrace("softap_stop_joined_in_flight", { mode: this.teardownMode });
501
- return this.stopping;
665
+ softapTrace("softap_stop_joined_in_flight", { mode: this.teardownMode, preserveMeeting: !!options.preserveMeeting });
666
+ await this.stopping;
667
+ // A Leave that joined a preserveMeeting teardown still has to drop ACS.
668
+ if (options.preserveMeeting || (!this.completed.includes("acsJoin") && this.completed.length === 0 && !this.running)) {
669
+ return;
670
+ }
502
671
  }
503
672
  const running = this.running;
504
- if (this.completed.length === 0 && this.phase === "idle" && !running) {
673
+ if (this.completed.length === 0 && (this.phase === "idle" || this.phase === "failed") && !running) {
505
674
  // Nothing was built, so there is nothing to unwind — but a start() that has not run yet
506
675
  // still has to be refused, and a generation bump still has to invalidate anything holding
507
676
  // the old one.
@@ -517,11 +686,21 @@ export class SoftapCallTransport {
517
686
  return;
518
687
  }
519
688
  this.generation++;
520
- this.phase = "stopping";
689
+ this.meetingPreserved = options.preserveMeeting === true;
690
+ this.phase = this.meetingPreserved ? "recovering" : "stopping";
521
691
  this.endFailure = null;
522
- softapTrace("softap_call_stop", { steps: this.completed.join(","), mode: this.teardownMode });
692
+ softapTrace("softap_call_stop", {
693
+ steps: this.completed.join(","),
694
+ mode: this.teardownMode,
695
+ preserveMeeting: this.meetingPreserved,
696
+ });
523
697
  this.emitProgress();
524
698
  this.stopping = (async () => {
699
+ const inFlightRepublish = this.republishing;
700
+ if (inFlightRepublish) {
701
+ softapTrace("softap_stop_draining_republish");
702
+ await inFlightRepublish.catch(() => undefined);
703
+ }
525
704
  // The generation bump above has already told the in-flight step to release whatever it
526
705
  // produced. Waiting for that release is what makes a resolved `stop()` mean "nothing from
527
706
  // this call is still coming". Deliberately unbounded: a native call that never returns must
@@ -549,7 +728,13 @@ export class SoftapCallTransport {
549
728
  // The late step may have recorded a failed self-undo while we waited. Preserve it,
550
729
  // and any earlier teardown result, until start() explicitly begins a new attempt.
551
730
  const failures = [...this.teardownFailures];
731
+ const kept = [];
552
732
  for (const step of [...this.completed].reverse()) {
733
+ if (this.meetingPreserved && step === "acsJoin") {
734
+ if (!kept.includes("acsJoin"))
735
+ kept.unshift(step);
736
+ continue;
737
+ }
553
738
  const undoStartedAt = Date.now();
554
739
  try {
555
740
  await this.undo(step);
@@ -567,10 +752,10 @@ export class SoftapCallTransport {
567
752
  });
568
753
  }
569
754
  }
570
- this.completed = [];
755
+ this.completed = kept;
571
756
  this.hotspot = null;
572
757
  this.ingestUrl = null;
573
- this.phase = "idle";
758
+ this.phase = this.meetingPreserved && !this.recoveryAbort ? "recovering" : "idle";
574
759
  this.teardownFailures = failures;
575
760
  softapTrace("softap_call_stopped", { undoFailures: failures.join(",") });
576
761
  resetSoftapTrace();
@@ -602,6 +787,8 @@ export class SoftapCallTransport {
602
787
  case "publish":
603
788
  return this.deps.stopPublishing();
604
789
  case "acsJoin":
790
+ if (this.meetingPreserved)
791
+ return;
605
792
  return this.leaveOrEndMeeting();
606
793
  case "scopedJoin":
607
794
  return this.deps.leaveScopedNetwork();
@@ -690,7 +877,8 @@ export class SoftapCallTransport {
690
877
  await this.undoSafely(step);
691
878
  throw new SoftapCallError(step, "CANCELLED", `SoftAP call was cancelled during ${step}`);
692
879
  }
693
- this.completed.push(step);
880
+ if (!this.completed.includes(step))
881
+ this.completed.push(step);
694
882
  softapTrace("softap_step_done", { step, durationMs: Date.now() - startedAt });
695
883
  this.setStep(step, { status: "done", durationMs: Date.now() - startedAt });
696
884
  }
@@ -756,11 +944,19 @@ export function createSoftapCallDeps(args) {
756
944
  catch (error) {
757
945
  if (error instanceof Error && /no password/.test(error.message))
758
946
  throw error;
947
+ const unanswered = isHotspotAnswerTimeout(error);
759
948
  // Cancel-then-start races the previous disable: the glasses report disabled (or no SSID)
760
949
  // and the UI said "Couldn't start glasses hotspot" before step 2 ran on a leftover AP.
761
950
  softapTraceFailure("hotspot_enable_retry", {
762
951
  reason: error instanceof Error ? error.message : String(error),
952
+ unanswered,
763
953
  });
954
+ if (unanswered) {
955
+ // Enable is idempotent; disabling here is what turned a late-delivered enable back off
956
+ // and cost the whole rebuild budget after a walk-away.
957
+ report?.("Glasses did not answer; asking again without turning the hotspot off");
958
+ return await enable();
959
+ }
764
960
  report?.("Glasses hotspot did not start; turning it off and trying again");
765
961
  await subsystems.setHotspotState(false);
766
962
  if (hotspotBroadcastWaitMs > 0) {
@@ -859,7 +1055,8 @@ export function createSoftapCallDeps(args) {
859
1055
  },
860
1056
  leaveMeeting: () => subsystems.leaveMeeting(packageName),
861
1057
  ...(subsystems.endMeeting ? { endMeeting: () => subsystems.endMeeting(packageName) } : {}),
862
- startPublishing: async ({ ingestUrl, traceId }, report) => {
1058
+ ...(subsystems.rebindIngest ? { rebindIngest: subsystems.rebindIngest } : {}),
1059
+ startPublishing: async ({ ingestUrl, traceId, mediaGeneration }, report) => {
863
1060
  // Narrate the glasses side while the BLE start command is in flight. `initializing` means
864
1061
  // the glasses accepted the command and are opening the camera; `streaming` means the WHIP
865
1062
  // offer was answered and ICE connected; anything else is the reason it did not.
@@ -907,6 +1104,7 @@ export function createSoftapCallDeps(args) {
907
1104
  ice: { stun: "" },
908
1105
  traceId,
909
1106
  captureAudio: !lc3Uplink,
1107
+ ...(typeof mediaGeneration === "number" ? { mediaGeneration } : {}),
910
1108
  ...(policy ? { video: policy } : {}),
911
1109
  });
912
1110
  }
@@ -914,7 +1112,10 @@ export function createSoftapCallDeps(args) {
914
1112
  unsubscribe?.();
915
1113
  }
916
1114
  },
917
- stopPublishing: () => subsystems.stopPublishing(packageName),
1115
+ stopPublishing: async () => {
1116
+ await subsystems.stopPublishing(packageName);
1117
+ subsystems.discardPendingBleStop?.();
1118
+ },
918
1119
  awaitFirstFrame: args.awaitFirstFrame,
919
1120
  waitUntilLive: args.waitUntilLive,
920
1121
  };