@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.
- package/build/generated/releaseMetadata.js +5 -5
- package/build/generated/releaseMetadata.js.map +1 -1
- package/build/services/AcsMeetingService.d.ts +25 -1
- package/build/services/AcsMeetingService.d.ts.map +1 -1
- package/build/services/AcsMeetingService.js +41 -3
- package/build/services/AcsMeetingService.js.map +1 -1
- package/build/services/LocalMiniappRuntime.d.ts +32 -3
- package/build/services/LocalMiniappRuntime.d.ts.map +1 -1
- package/build/services/LocalMiniappRuntime.js +284 -30
- package/build/services/LocalMiniappRuntime.js.map +1 -1
- package/build/services/MiniappLiveness.d.ts +11 -0
- package/build/services/MiniappLiveness.d.ts.map +1 -1
- package/build/services/MiniappLiveness.js +11 -0
- package/build/services/MiniappLiveness.js.map +1 -1
- package/build/services/PhoneStreamCoordinator.d.ts +13 -1
- package/build/services/PhoneStreamCoordinator.d.ts.map +1 -1
- package/build/services/PhoneStreamCoordinator.js +27 -4
- package/build/services/PhoneStreamCoordinator.js.map +1 -1
- package/build/services/SoftapCallTransport.d.ts +87 -7
- package/build/services/SoftapCallTransport.d.ts.map +1 -1
- package/build/services/SoftapCallTransport.js +229 -28
- package/build/services/SoftapCallTransport.js.map +1 -1
- package/build/utils/miniappGlobals.d.ts.map +1 -1
- package/build/utils/miniappGlobals.js +13 -11
- package/build/utils/miniappGlobals.js.map +1 -1
- package/package.json +8 -8
- package/src/generated/releaseMetadata.ts +5 -5
- package/src/services/AcsMeetingService.ts +51 -3
- package/src/services/LocalMiniappRuntime.ts +296 -30
- package/src/services/MiniappLiveness.ts +18 -0
- package/src/services/PhoneStreamCoordinator.ts +27 -5
- package/src/services/SoftapCallTransport.ts +274 -32
- package/src/utils/miniappGlobals.ts +13 -11
|
@@ -230,9 +230,12 @@ export class PhoneStreamCoordinator {
|
|
|
230
230
|
* A stream was torn down while the BLE link was down, so the glasses never
|
|
231
231
|
* got `stopStream`. Sent on the next reconnect (if no new stream has claimed
|
|
232
232
|
* the slot) so a publisher that outlived its input does not keep pushing
|
|
233
|
-
* until its own watchdog fires.
|
|
233
|
+
* until its own watchdog fires. `generation` belongs to the publisher that
|
|
234
|
+
* queued it; a later SoftAP media hop must not inherit this stop.
|
|
234
235
|
*/
|
|
235
|
-
private pendingBleStop: {streamId: string; hotspot?: boolean} | null = null
|
|
236
|
+
private pendingBleStop: {streamId: string; hotspot?: boolean; generation: number; discarded?: boolean} | null = null
|
|
237
|
+
/** Bumped each time an unmanaged/managed publisher claims the slot. */
|
|
238
|
+
private publisherGeneration = 0
|
|
236
239
|
/**
|
|
237
240
|
* Serializes state transitions (start, stop, teardown). Without it, a
|
|
238
241
|
* second `start*` racing with the first can pass the `this.current === null`
|
|
@@ -363,6 +366,7 @@ export class PhoneStreamCoordinator {
|
|
|
363
366
|
}
|
|
364
367
|
|
|
365
368
|
const streamId = this.mintId("u")
|
|
369
|
+
++this.publisherGeneration
|
|
366
370
|
const entry: UnmanagedEntry = {
|
|
367
371
|
kind: "unmanaged",
|
|
368
372
|
streamId,
|
|
@@ -461,6 +465,7 @@ export class PhoneStreamCoordinator {
|
|
|
461
465
|
// and joins instead of double-provisioning.
|
|
462
466
|
const provision = await provisionManagedStream(opts.restreamDestinations)
|
|
463
467
|
const streamId = this.mintId("m")
|
|
468
|
+
++this.publisherGeneration
|
|
464
469
|
let ingestUrl: string
|
|
465
470
|
try {
|
|
466
471
|
ingestUrl = pickIngestUrl(provision, opts.ingest)
|
|
@@ -513,7 +518,7 @@ export class PhoneStreamCoordinator {
|
|
|
513
518
|
},
|
|
514
519
|
() => this.linkSource.isConnected(),
|
|
515
520
|
() => {
|
|
516
|
-
this.pendingBleStop = {streamId, hotspot: true}
|
|
521
|
+
this.pendingBleStop = {streamId, hotspot: true, generation: this.publisherGeneration}
|
|
517
522
|
this.attachLink()
|
|
518
523
|
},
|
|
519
524
|
)
|
|
@@ -615,6 +620,22 @@ export class PhoneStreamCoordinator {
|
|
|
615
620
|
})
|
|
616
621
|
}
|
|
617
622
|
|
|
623
|
+
/**
|
|
624
|
+
* Drop a deferred BLE `stopStream` that belonged to a publisher that is gone.
|
|
625
|
+
*
|
|
626
|
+
* SoftAP recovery destroys generation N and rebuilds N+1. If failSuspended already
|
|
627
|
+
* tore the publisher down after `glassesGraceMs`, `stop()` is a no-op but a pending
|
|
628
|
+
* stop would still flush into the new hop on reconnect. Call this from SoftAP
|
|
629
|
+
* `stopPublishing` so the deferred command dies with its generation.
|
|
630
|
+
*/
|
|
631
|
+
discardPendingBleStop(): void {
|
|
632
|
+
const pending = this.pendingBleStop
|
|
633
|
+
if (!pending) return
|
|
634
|
+
pending.discarded = true
|
|
635
|
+
this.pendingBleStop = null
|
|
636
|
+
this.detachLinkIfIdle()
|
|
637
|
+
}
|
|
638
|
+
|
|
618
639
|
/**
|
|
619
640
|
* Called by MantleManager when a `stream_status` event arrives from glasses
|
|
620
641
|
* and the registry says it's phone-owned.
|
|
@@ -759,9 +780,10 @@ export class PhoneStreamCoordinator {
|
|
|
759
780
|
|
|
760
781
|
private async flushPendingBleStop(): Promise<void> {
|
|
761
782
|
const pending = this.pendingBleStop
|
|
762
|
-
if (!pending || this.current || !this.linkSource.isConnected()) return
|
|
783
|
+
if (!pending || pending.discarded || this.current || !this.linkSource.isConnected()) return
|
|
763
784
|
console.info("[STREAM] BLE link back; sending deferred stopStream", pending)
|
|
764
785
|
await BluetoothSdk.stopStream()
|
|
786
|
+
if (pending.discarded || this.pendingBleStop !== pending || this.current) return
|
|
765
787
|
if (pending.hotspot) {
|
|
766
788
|
const result = await BluetoothSdk.setHotspotState(false)
|
|
767
789
|
if (result.state !== "disabled") throw new Error("Deferred hotspot shutdown was not confirmed")
|
|
@@ -1009,7 +1031,7 @@ export class PhoneStreamCoordinator {
|
|
|
1009
1031
|
// lock for the native timeout). Defer it to the next reconnect instead.
|
|
1010
1032
|
const linkUp = this.linkSource.isConnected()
|
|
1011
1033
|
if (sendBleStop && !linkUp) {
|
|
1012
|
-
this.pendingBleStop = {streamId: entry.streamId, hotspot: entry.kind === "managed" && !!entry.relay}
|
|
1034
|
+
this.pendingBleStop = {streamId: entry.streamId, hotspot: entry.kind === "managed" && !!entry.relay, generation: this.publisherGeneration}
|
|
1013
1035
|
console.warn("[STREAM] BLE link down during teardown; stopStream deferred", {
|
|
1014
1036
|
streamId: entry.streamId,
|
|
1015
1037
|
reason,
|
|
@@ -33,7 +33,23 @@ export type SoftapStep = (typeof SOFTAP_STEPS)[number]
|
|
|
33
33
|
* `starting` covers every step up to `live` because the caller's only useful distinction is
|
|
34
34
|
* "not yet usable" versus "carrying media"; the step names are for diagnostics, not for branching.
|
|
35
35
|
*/
|
|
36
|
-
export type SoftapPhase = "idle" | "starting" | "live" | "stopping" | "failed"
|
|
36
|
+
export type SoftapPhase = "idle" | "starting" | "recovering" | "live" | "stopping" | "failed"
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* SoftAP loss is a media outage, not call termination. The wearer has this long to
|
|
40
|
+
* come back in range before the host should give up rebuilding the hop.
|
|
41
|
+
*/
|
|
42
|
+
export const RETURN_DEADLINE_MS = 60_000
|
|
43
|
+
|
|
44
|
+
/** Budget to re-arm hotspot, scoped join, ingest, and publish after the glasses return. */
|
|
45
|
+
export const REARM_BUDGET_MS = 45_000
|
|
46
|
+
|
|
47
|
+
/** Snapshot a host can use to decide whether to keep waiting or stand the call down. */
|
|
48
|
+
export interface SoftapRecoveryState {
|
|
49
|
+
phase: SoftapPhase
|
|
50
|
+
mediaGeneration: number
|
|
51
|
+
reason?: string
|
|
52
|
+
}
|
|
37
53
|
|
|
38
54
|
/** A failure, named by the step that produced it so the UI and the logs agree on the cause. */
|
|
39
55
|
export class SoftapCallError extends Error {
|
|
@@ -125,6 +141,17 @@ function isScopedJoinUnavailable(error: unknown): boolean {
|
|
|
125
141
|
)
|
|
126
142
|
}
|
|
127
143
|
|
|
144
|
+
/**
|
|
145
|
+
* The glasses never answered, as opposed to answering "disabled".
|
|
146
|
+
*
|
|
147
|
+
* A timeout is not evidence the command was lost: BLE can redeliver it after the phone has given
|
|
148
|
+
* up, so the AP may be coming up at the very moment the retry would ask for it to go down.
|
|
149
|
+
*/
|
|
150
|
+
function isHotspotAnswerTimeout(error: unknown): boolean {
|
|
151
|
+
const message = error instanceof Error ? error.message : String(error)
|
|
152
|
+
return /timed out waiting for glasses response|glasses did not answer/i.test(message)
|
|
153
|
+
}
|
|
154
|
+
|
|
128
155
|
export type SoftapStepStatus = "pending" | "running" | "done" | "failed"
|
|
129
156
|
|
|
130
157
|
/**
|
|
@@ -150,6 +177,8 @@ export interface SoftapProgress {
|
|
|
150
177
|
steps: SoftapStepState[]
|
|
151
178
|
/** ms since `start()` was called. */
|
|
152
179
|
elapsedMs: number
|
|
180
|
+
/** Media hop generation. Bumped on recover / media-only rebuild; ACS join is not. */
|
|
181
|
+
mediaGeneration: number
|
|
153
182
|
}
|
|
154
183
|
|
|
155
184
|
/** Sub-status callback a step can use to narrate what it is doing while it runs. */
|
|
@@ -196,15 +225,26 @@ export interface SoftapCallDeps {
|
|
|
196
225
|
* tears down correctly, it just cannot honour `stop({mode: "end"})`.
|
|
197
226
|
*/
|
|
198
227
|
endMeeting?(): Promise<void>
|
|
228
|
+
/**
|
|
229
|
+
* Rebind the local WHIP listener onto the standing ACS session. Substitutes for
|
|
230
|
+
* [joinMeeting] on a media-only rebuild: the meeting stays up, the ingest URL is new.
|
|
231
|
+
*/
|
|
232
|
+
rebindIngest?(report?: SoftapStepReporter): Promise<{ingestUrl: string}>
|
|
199
233
|
/** Tell the glasses to publish to [ingestUrl] in host-only ICE mode. */
|
|
200
|
-
startPublishing(
|
|
234
|
+
startPublishing(
|
|
235
|
+
args: {ingestUrl: string; traceId: string; mediaGeneration?: number},
|
|
236
|
+
report?: SoftapStepReporter,
|
|
237
|
+
): Promise<void>
|
|
201
238
|
stopPublishing(): Promise<void>
|
|
202
239
|
/**
|
|
203
240
|
* Resolves when a frame has reached ACS, rejects if the feed failed or the deadline passed.
|
|
204
241
|
* Separate from [joinMeeting] because an answered negotiation is not a working call: a session
|
|
205
242
|
* that never delivers a frame reads as healthy behind a frozen tile.
|
|
243
|
+
*
|
|
244
|
+
* [fresh] ignores a standing `live` verdict. Recovery must wait for a frame from the new
|
|
245
|
+
* ingest generation; the previous hop's last decoded frame is not that.
|
|
206
246
|
*/
|
|
207
|
-
awaitFirstFrame(report?: SoftapStepReporter): Promise<void>
|
|
247
|
+
awaitFirstFrame(report?: SoftapStepReporter, options?: {fresh?: boolean}): Promise<void>
|
|
208
248
|
/**
|
|
209
249
|
* Mid-call camera recovery. Resolves `true` only when ingest is live again.
|
|
210
250
|
* A standing `failed` is the reason we are republishing, so it must not abort the wait.
|
|
@@ -215,6 +255,11 @@ export interface SoftapCallDeps {
|
|
|
215
255
|
}
|
|
216
256
|
|
|
217
257
|
export interface SoftapCallOptions {
|
|
258
|
+
/**
|
|
259
|
+
* Rebuild the media hop only. Skips `joinMeeting` and calls [SoftapCallDeps.rebindIngest]
|
|
260
|
+
* for the `acsJoin` step so the ACS session, endpoints, and mute intent stay up.
|
|
261
|
+
*/
|
|
262
|
+
mediaOnly?: boolean
|
|
218
263
|
/** Override the minted trace id, so a caller can correlate with logs it already started. */
|
|
219
264
|
traceId?: string
|
|
220
265
|
/**
|
|
@@ -274,6 +319,11 @@ export type SoftapTeardownMode = "leave" | "end"
|
|
|
274
319
|
export interface SoftapStopOptions {
|
|
275
320
|
mode?: SoftapTeardownMode
|
|
276
321
|
keepProgress?: boolean
|
|
322
|
+
/**
|
|
323
|
+
* Tear down the media hop and keep the ACS session. Skips only the `acsJoin` undo
|
|
324
|
+
* (`leaveOrEndMeeting`) and leaves `acsJoin` in [completed].
|
|
325
|
+
*/
|
|
326
|
+
preserveMeeting?: boolean
|
|
277
327
|
}
|
|
278
328
|
|
|
279
329
|
export class SoftapEndNotSupportedError extends Error {
|
|
@@ -341,6 +391,24 @@ export class SoftapCallTransport {
|
|
|
341
391
|
private republishing: Promise<void> | null = null
|
|
342
392
|
/** Bumped by `stop` so an in-flight republish cannot start_stream after Leave. */
|
|
343
393
|
private republishGeneration = 0
|
|
394
|
+
/**
|
|
395
|
+
* Media hop generation. Distinct from [generation]: ACS join is preserved across recoveries,
|
|
396
|
+
* but hotspot, scoped network, ingest, and publisher belong to this number.
|
|
397
|
+
*/
|
|
398
|
+
private mediaGeneration = 0
|
|
399
|
+
/** Why the current recover was requested. Cleared when the hop is live again. */
|
|
400
|
+
private recoveryReason: string | undefined
|
|
401
|
+
/** In-flight [recover]. Concurrent recoveries share one rebuild. */
|
|
402
|
+
private recovering: Promise<void> | null = null
|
|
403
|
+
/** A [stop] without preserveMeeting landed during recover; recover must not rebuild. */
|
|
404
|
+
private recoveryAbort = false
|
|
405
|
+
/** Skip `acsJoin` undo so the ACS session survives a media-only teardown. */
|
|
406
|
+
private meetingPreserved = false
|
|
407
|
+
/**
|
|
408
|
+
* Identity of the publisher the most recent `startPublishing` created.
|
|
409
|
+
* A cancelled republish may only `stopPublishing` if it still owns this token.
|
|
410
|
+
*/
|
|
411
|
+
private publisherToken: object | null = null
|
|
344
412
|
|
|
345
413
|
constructor(private readonly deps: SoftapCallDeps) {}
|
|
346
414
|
|
|
@@ -348,6 +416,18 @@ export class SoftapCallTransport {
|
|
|
348
416
|
return this.phase
|
|
349
417
|
}
|
|
350
418
|
|
|
419
|
+
currentMediaGeneration(): number {
|
|
420
|
+
return this.mediaGeneration
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
recoveryState(): SoftapRecoveryState {
|
|
424
|
+
return {
|
|
425
|
+
phase: this.phase,
|
|
426
|
+
mediaGeneration: this.mediaGeneration,
|
|
427
|
+
...(this.recoveryReason ? {reason: this.recoveryReason} : {}),
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
|
|
351
431
|
/**
|
|
352
432
|
* The glasses camera died after this call was already live. Re-issue `start_stream` at the
|
|
353
433
|
* existing ingest URL — do not rebind the WHIP listener, or the glasses POST to a dead port.
|
|
@@ -366,10 +446,14 @@ export class SoftapCallTransport {
|
|
|
366
446
|
if (this.phase !== "live" || this.terminating || !this.ingestUrl) return Promise.resolve()
|
|
367
447
|
if (this.republishing) return this.republishing
|
|
368
448
|
const generation = this.republishGeneration
|
|
369
|
-
|
|
370
|
-
|
|
449
|
+
let run!: Promise<void>
|
|
450
|
+
run = this.runRepublish(reason, generation).finally(() => {
|
|
451
|
+
// Clear when *this* run still owns the field, even if stop() bumped the generation.
|
|
452
|
+
// Gating on generation left a settled promise stranded and the next republish was a no-op.
|
|
453
|
+
if (this.republishing === run) this.republishing = null
|
|
371
454
|
})
|
|
372
|
-
|
|
455
|
+
this.republishing = run
|
|
456
|
+
return run
|
|
373
457
|
}
|
|
374
458
|
|
|
375
459
|
private async runRepublish(reason: string, generation: number): Promise<void> {
|
|
@@ -385,12 +469,18 @@ export class SoftapCallTransport {
|
|
|
385
469
|
this.ingestUrl === ingestUrl
|
|
386
470
|
) {
|
|
387
471
|
attempt += 1
|
|
472
|
+
let startedPublisher = false
|
|
473
|
+
const token = {}
|
|
388
474
|
try {
|
|
389
475
|
await this.deps.stopPublishing()
|
|
390
476
|
if (generation !== this.republishGeneration || this.terminating) return
|
|
391
|
-
await this.
|
|
477
|
+
await this.startPublishingOwned(token, {ingestUrl, traceId: this.traceId})
|
|
478
|
+
startedPublisher = true
|
|
392
479
|
if (generation !== this.republishGeneration || this.terminating) {
|
|
393
|
-
|
|
480
|
+
// Only stop a publisher this run started, and only if a successor has not taken it.
|
|
481
|
+
if (startedPublisher && this.publisherToken === token) {
|
|
482
|
+
await this.deps.stopPublishing().catch(() => undefined)
|
|
483
|
+
}
|
|
394
484
|
return
|
|
395
485
|
}
|
|
396
486
|
softapTrace("glasses_republish_sent", {attempt, ingestUrl})
|
|
@@ -413,6 +503,93 @@ export class SoftapCallTransport {
|
|
|
413
503
|
}
|
|
414
504
|
}
|
|
415
505
|
|
|
506
|
+
/**
|
|
507
|
+
* Rebuild the glasses→phone hop after SoftAP loss. ACS, mute intent, and media endpoints stay.
|
|
508
|
+
*
|
|
509
|
+
* Phase flips to `recovering` before any await so [shouldRepublish] stands down immediately —
|
|
510
|
+
* not after a BLE wait that could still issue `start_stream` at the dying hop.
|
|
511
|
+
*/
|
|
512
|
+
recover(reason: string, options: {wait?: () => Promise<void>} = {}): Promise<void> {
|
|
513
|
+
if (this.recovering) return this.recovering
|
|
514
|
+
if (this.phase !== "live") {
|
|
515
|
+
return Promise.reject(
|
|
516
|
+
new SoftapCallError("live", "NOT_RECOVERABLE", `Cannot recover a SoftAP call that is ${this.phase}`),
|
|
517
|
+
)
|
|
518
|
+
}
|
|
519
|
+
this.phase = "recovering"
|
|
520
|
+
this.recoveryReason = reason
|
|
521
|
+
this.recoveryAbort = false
|
|
522
|
+
this.mediaGeneration++
|
|
523
|
+
this.emitProgress()
|
|
524
|
+
softapTrace("softap_media_recover", {reason, mediaGeneration: this.mediaGeneration})
|
|
525
|
+
|
|
526
|
+
let run!: Promise<void>
|
|
527
|
+
run = this.runRecover(options).finally(() => {
|
|
528
|
+
if (this.recovering === run) this.recovering = null
|
|
529
|
+
})
|
|
530
|
+
this.recovering = run
|
|
531
|
+
return run
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
private async runRecover(options: {wait?: () => Promise<void>}): Promise<void> {
|
|
535
|
+
const onProgress = this.onProgress
|
|
536
|
+
// Wait for BLE *before* `set_hotspot_state false`. Doing stop first while the glasses are
|
|
537
|
+
// out of range queues the off command; when BLE returns it fires into a still-up hotspot
|
|
538
|
+
// and races the media-only start.
|
|
539
|
+
if (options.wait) await options.wait()
|
|
540
|
+
if (this.recoveryAbort) {
|
|
541
|
+
throw new SoftapCallError("hotspot", "CANCELLED", "SoftAP recovery was cancelled")
|
|
542
|
+
}
|
|
543
|
+
await this.stop({preserveMeeting: true, keepProgress: true})
|
|
544
|
+
if (this.recoveryAbort) {
|
|
545
|
+
throw new SoftapCallError("hotspot", "CANCELLED", "SoftAP recovery was cancelled")
|
|
546
|
+
}
|
|
547
|
+
// The return deadline bounds [options.wait]; the rebuild gets its own budget from the moment
|
|
548
|
+
// the glasses are back. Charging the rebuild for the time the wearer spent away left a return
|
|
549
|
+
// at 48s with 12s to re-arm a hotspot that takes ~30s, and the call was dropped as it worked.
|
|
550
|
+
const rearmMs = REARM_BUDGET_MS
|
|
551
|
+
let budgetTimer: ReturnType<typeof setTimeout> | undefined
|
|
552
|
+
const budget = new Promise<never>((_, reject) => {
|
|
553
|
+
budgetTimer = setTimeout(() => {
|
|
554
|
+
void this.stop({preserveMeeting: true}).catch(() => undefined)
|
|
555
|
+
reject(new SoftapCallError("hotspot", "REARM_BUDGET", "SoftAP media rebuild exceeded its budget"))
|
|
556
|
+
}, rearmMs)
|
|
557
|
+
})
|
|
558
|
+
try {
|
|
559
|
+
await Promise.race([this.start({mediaOnly: true, onProgress}), budget])
|
|
560
|
+
} finally {
|
|
561
|
+
if (budgetTimer !== undefined) clearTimeout(budgetTimer)
|
|
562
|
+
}
|
|
563
|
+
this.completed = [...SOFTAP_STEPS]
|
|
564
|
+
this.recoveryReason = undefined
|
|
565
|
+
this.emitProgress()
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
/** Issue `start_stream` and take publisher ownership so a stale republish cannot stop a successor. */
|
|
569
|
+
private async startPublishingOwned(
|
|
570
|
+
token: object,
|
|
571
|
+
args: {ingestUrl: string; traceId: string},
|
|
572
|
+
report?: SoftapStepReporter,
|
|
573
|
+
): Promise<void> {
|
|
574
|
+
const mediaGeneration = this.mediaGeneration
|
|
575
|
+
const stamped: SoftapStepReporter | undefined = report
|
|
576
|
+
? (detail) => {
|
|
577
|
+
if (mediaGeneration !== this.mediaGeneration) {
|
|
578
|
+
softapTrace("softap_stale_callback", {
|
|
579
|
+
source: "startPublishing",
|
|
580
|
+
mediaGeneration,
|
|
581
|
+
current: this.mediaGeneration,
|
|
582
|
+
detail,
|
|
583
|
+
})
|
|
584
|
+
return
|
|
585
|
+
}
|
|
586
|
+
report(detail)
|
|
587
|
+
}
|
|
588
|
+
: undefined
|
|
589
|
+
await this.deps.startPublishing({...args, mediaGeneration}, stamped)
|
|
590
|
+
this.publisherToken = token
|
|
591
|
+
}
|
|
592
|
+
|
|
416
593
|
/**
|
|
417
594
|
* True once a teardown has been decided. Anything that would otherwise report a failure — a lost
|
|
418
595
|
* hotspot, a dropped ACS call — must check this first: after the wearer asks to leave, those are
|
|
@@ -429,6 +606,7 @@ export class SoftapCallTransport {
|
|
|
429
606
|
phase: this.phase,
|
|
430
607
|
steps: this.steps.map((step) => ({...step})),
|
|
431
608
|
elapsedMs: this.startedAt ? Date.now() - this.startedAt : 0,
|
|
609
|
+
mediaGeneration: this.mediaGeneration,
|
|
432
610
|
}
|
|
433
611
|
}
|
|
434
612
|
|
|
@@ -473,11 +651,10 @@ export class SoftapCallTransport {
|
|
|
473
651
|
}
|
|
474
652
|
|
|
475
653
|
/**
|
|
476
|
-
* Steps whose undo threw during the last teardown
|
|
654
|
+
* Steps whose undo threw during the last teardown.
|
|
477
655
|
*
|
|
478
|
-
* Teardown
|
|
479
|
-
*
|
|
480
|
-
* "Cannot start glasses hotspot" followed by a scoped join that never found the SSID.
|
|
656
|
+
* Teardown swallows these so unwind can finish. The host refuses the next join for ingest /
|
|
657
|
+
* scoped-network leaks; a hotspot that missed its off-ack is leftover ON and is raised again.
|
|
481
658
|
*/
|
|
482
659
|
lastTeardownFailures(): SoftapStep[] {
|
|
483
660
|
// Logged on read rather than only on write, because this is the moment the list turns into a
|
|
@@ -538,17 +715,22 @@ export class SoftapCallTransport {
|
|
|
538
715
|
softapTraceFailure("softap_call_refused", {reason: "cancelled before start"})
|
|
539
716
|
throw new SoftapCallError("hotspot", "CANCELLED", "SoftAP call was cancelled before it started")
|
|
540
717
|
}
|
|
541
|
-
|
|
718
|
+
const mediaOnly = options.mediaOnly === true
|
|
719
|
+
const allowed =
|
|
720
|
+
this.phase === "idle" || this.phase === "failed" || (mediaOnly && this.phase === "recovering")
|
|
721
|
+
if (!allowed) {
|
|
542
722
|
softapTraceFailure("softap_call_refused", {reason: "already active", phase: this.phase})
|
|
543
723
|
throw new SoftapCallError("hotspot", "ALREADY_ACTIVE", `A SoftAP call is already ${this.phase}`)
|
|
544
724
|
}
|
|
545
725
|
this.startedEver = true
|
|
546
726
|
const generation = ++this.generation
|
|
547
|
-
this.
|
|
727
|
+
if (mediaOnly) this.mediaGeneration++
|
|
728
|
+
this.phase = mediaOnly ? "recovering" : "starting"
|
|
548
729
|
this.terminating = false
|
|
549
730
|
this.teardownMode = "leave"
|
|
550
731
|
this.endFailure = null
|
|
551
|
-
this.
|
|
732
|
+
this.meetingPreserved = mediaOnly
|
|
733
|
+
this.completed = mediaOnly ? this.completed.filter((step) => step === "acsJoin") : []
|
|
552
734
|
this.ingestUrl = null
|
|
553
735
|
this.hotspot = null
|
|
554
736
|
this.teardownFailures = []
|
|
@@ -556,12 +738,17 @@ export class SoftapCallTransport {
|
|
|
556
738
|
this.stepStartedAt.clear()
|
|
557
739
|
this.startedAt = Date.now()
|
|
558
740
|
this.onProgress = options.onProgress
|
|
559
|
-
|
|
741
|
+
// Media-only rebuilds mint a fresh trace id: the previous hop is gone and its id must not
|
|
742
|
+
// be reused, even if the caller still holds it.
|
|
743
|
+
const traceId = beginSoftapTrace(mediaOnly ? undefined : options.traceId)
|
|
560
744
|
this.traceId = traceId
|
|
561
|
-
softapTrace("softap_call_start", {traceId})
|
|
745
|
+
softapTrace("softap_call_start", {traceId, mediaOnly, mediaGeneration: this.mediaGeneration})
|
|
562
746
|
this.emitProgress()
|
|
563
747
|
|
|
564
748
|
try {
|
|
749
|
+
if (mediaOnly && !this.deps.rebindIngest) {
|
|
750
|
+
throw new SoftapCallError("acsJoin", "ACS_JOIN_FAILED", "rebindIngest is required for a media-only rebuild")
|
|
751
|
+
}
|
|
565
752
|
await this.preflightWifi()
|
|
566
753
|
|
|
567
754
|
await this.step(generation, "hotspot", "HOTSPOT_FAILED", async (report) => {
|
|
@@ -590,6 +777,17 @@ export class SoftapCallTransport {
|
|
|
590
777
|
})
|
|
591
778
|
|
|
592
779
|
await this.step(generation, "acsJoin", "ACS_JOIN_FAILED", async (report) => {
|
|
780
|
+
if (mediaOnly) {
|
|
781
|
+
report(bindAddress ? `Rebinding the video receiver on ${bindAddress}` : "Rebinding the video receiver")
|
|
782
|
+
const {ingestUrl} = await this.deps.rebindIngest!(report)
|
|
783
|
+
if (!ingestUrl) {
|
|
784
|
+
throw new Error("the meeting reported no ingest URL")
|
|
785
|
+
}
|
|
786
|
+
this.ingestUrl = ingestUrl
|
|
787
|
+
softapTrace("acs_receiver_rebound", {ingestUrl})
|
|
788
|
+
report(`Receiver ready at ${ingestUrl}`)
|
|
789
|
+
return
|
|
790
|
+
}
|
|
593
791
|
report(bindAddress ? `Opening video receiver on ${bindAddress}, then joining Teams` : "Joining Teams")
|
|
594
792
|
const {ingestUrl} = await this.deps.joinMeeting(
|
|
595
793
|
{
|
|
@@ -612,14 +810,14 @@ export class SoftapCallTransport {
|
|
|
612
810
|
const ingestUrl = this.requireIngestUrl()
|
|
613
811
|
await this.step(generation, "publish", "PUBLISH_FAILED", async (report) => {
|
|
614
812
|
report("Telling the glasses to start the camera and publish to the phone")
|
|
615
|
-
await this.
|
|
813
|
+
await this.startPublishingOwned({}, {ingestUrl, traceId}, report)
|
|
616
814
|
softapTrace("glasses_publishing", {ingestUrl})
|
|
617
815
|
report("Glasses camera is streaming to the phone")
|
|
618
816
|
})
|
|
619
817
|
|
|
620
818
|
await this.step(generation, "live", "NO_FIRST_FRAME", async (report) => {
|
|
621
819
|
report("Waiting for the first glasses video frame on this phone")
|
|
622
|
-
await this.deps.awaitFirstFrame(report)
|
|
820
|
+
await this.deps.awaitFirstFrame(report, mediaOnly ? {fresh: true} : undefined)
|
|
623
821
|
softapTrace("first_glasses_frame_received")
|
|
624
822
|
report("Glasses video is reaching this phone")
|
|
625
823
|
})
|
|
@@ -631,14 +829,17 @@ export class SoftapCallTransport {
|
|
|
631
829
|
softapTraceFailure("softap_call_abandoned_at_live", {generation, current: this.generation})
|
|
632
830
|
return
|
|
633
831
|
}
|
|
832
|
+
if (mediaOnly) this.completed = [...SOFTAP_STEPS]
|
|
634
833
|
this.phase = "live"
|
|
834
|
+
this.recoveryReason = undefined
|
|
635
835
|
softapTrace("softap_call_live")
|
|
636
836
|
this.emitProgress()
|
|
637
837
|
} catch (error) {
|
|
638
838
|
// Unwind before rethrowing. A caller that sees a rejection is entitled to assume nothing was
|
|
639
839
|
// left running, and a hotspot left up is both a battery cost and a second call's failure.
|
|
640
|
-
|
|
641
|
-
this.
|
|
840
|
+
// Media-only failures keep ACS; a user Leave during recover does not (`recoveryAbort`).
|
|
841
|
+
await this.stop({keepProgress: true, preserveMeeting: mediaOnly && !this.recoveryAbort})
|
|
842
|
+
if (!(mediaOnly && this.recoveryAbort)) this.phase = "failed"
|
|
642
843
|
this.emitProgress()
|
|
643
844
|
throw error
|
|
644
845
|
}
|
|
@@ -663,12 +864,17 @@ export class SoftapCallTransport {
|
|
|
663
864
|
this.terminating = true
|
|
664
865
|
this.republishGeneration++
|
|
665
866
|
if (options.mode) this.teardownMode = options.mode
|
|
867
|
+
if (!options.preserveMeeting) this.recoveryAbort = true
|
|
666
868
|
if (this.stopping) {
|
|
667
|
-
softapTrace("softap_stop_joined_in_flight", {mode: this.teardownMode})
|
|
668
|
-
|
|
869
|
+
softapTrace("softap_stop_joined_in_flight", {mode: this.teardownMode, preserveMeeting: !!options.preserveMeeting})
|
|
870
|
+
await this.stopping
|
|
871
|
+
// A Leave that joined a preserveMeeting teardown still has to drop ACS.
|
|
872
|
+
if (options.preserveMeeting || (!this.completed.includes("acsJoin") && this.completed.length === 0 && !this.running)) {
|
|
873
|
+
return
|
|
874
|
+
}
|
|
669
875
|
}
|
|
670
876
|
const running = this.running
|
|
671
|
-
if (this.completed.length === 0 && this.phase === "idle" && !running) {
|
|
877
|
+
if (this.completed.length === 0 && (this.phase === "idle" || this.phase === "failed") && !running) {
|
|
672
878
|
// Nothing was built, so there is nothing to unwind — but a start() that has not run yet
|
|
673
879
|
// still has to be refused, and a generation bump still has to invalidate anything holding
|
|
674
880
|
// the old one.
|
|
@@ -684,12 +890,22 @@ export class SoftapCallTransport {
|
|
|
684
890
|
}
|
|
685
891
|
|
|
686
892
|
this.generation++
|
|
687
|
-
this.
|
|
893
|
+
this.meetingPreserved = options.preserveMeeting === true
|
|
894
|
+
this.phase = this.meetingPreserved ? "recovering" : "stopping"
|
|
688
895
|
this.endFailure = null
|
|
689
|
-
softapTrace("softap_call_stop", {
|
|
896
|
+
softapTrace("softap_call_stop", {
|
|
897
|
+
steps: this.completed.join(","),
|
|
898
|
+
mode: this.teardownMode,
|
|
899
|
+
preserveMeeting: this.meetingPreserved,
|
|
900
|
+
})
|
|
690
901
|
this.emitProgress()
|
|
691
902
|
|
|
692
903
|
this.stopping = (async () => {
|
|
904
|
+
const inFlightRepublish = this.republishing
|
|
905
|
+
if (inFlightRepublish) {
|
|
906
|
+
softapTrace("softap_stop_draining_republish")
|
|
907
|
+
await inFlightRepublish.catch(() => undefined)
|
|
908
|
+
}
|
|
693
909
|
// The generation bump above has already told the in-flight step to release whatever it
|
|
694
910
|
// produced. Waiting for that release is what makes a resolved `stop()` mean "nothing from
|
|
695
911
|
// this call is still coming". Deliberately unbounded: a native call that never returns must
|
|
@@ -717,7 +933,12 @@ export class SoftapCallTransport {
|
|
|
717
933
|
// The late step may have recorded a failed self-undo while we waited. Preserve it,
|
|
718
934
|
// and any earlier teardown result, until start() explicitly begins a new attempt.
|
|
719
935
|
const failures: SoftapStep[] = [...this.teardownFailures]
|
|
936
|
+
const kept: SoftapStep[] = []
|
|
720
937
|
for (const step of [...this.completed].reverse()) {
|
|
938
|
+
if (this.meetingPreserved && step === "acsJoin") {
|
|
939
|
+
if (!kept.includes("acsJoin")) kept.unshift(step)
|
|
940
|
+
continue
|
|
941
|
+
}
|
|
721
942
|
const undoStartedAt = Date.now()
|
|
722
943
|
try {
|
|
723
944
|
await this.undo(step)
|
|
@@ -734,10 +955,10 @@ export class SoftapCallTransport {
|
|
|
734
955
|
})
|
|
735
956
|
}
|
|
736
957
|
}
|
|
737
|
-
this.completed =
|
|
958
|
+
this.completed = kept
|
|
738
959
|
this.hotspot = null
|
|
739
960
|
this.ingestUrl = null
|
|
740
|
-
this.phase = "idle"
|
|
961
|
+
this.phase = this.meetingPreserved && !this.recoveryAbort ? "recovering" : "idle"
|
|
741
962
|
this.teardownFailures = failures
|
|
742
963
|
softapTrace("softap_call_stopped", {undoFailures: failures.join(",")})
|
|
743
964
|
resetSoftapTrace()
|
|
@@ -769,6 +990,7 @@ export class SoftapCallTransport {
|
|
|
769
990
|
case "publish":
|
|
770
991
|
return this.deps.stopPublishing()
|
|
771
992
|
case "acsJoin":
|
|
993
|
+
if (this.meetingPreserved) return
|
|
772
994
|
return this.leaveOrEndMeeting()
|
|
773
995
|
case "scopedJoin":
|
|
774
996
|
return this.deps.leaveScopedNetwork()
|
|
@@ -865,7 +1087,7 @@ export class SoftapCallTransport {
|
|
|
865
1087
|
await this.undoSafely(step)
|
|
866
1088
|
throw new SoftapCallError(step, "CANCELLED", `SoftAP call was cancelled during ${step}`)
|
|
867
1089
|
}
|
|
868
|
-
this.completed.push(step)
|
|
1090
|
+
if (!this.completed.includes(step)) this.completed.push(step)
|
|
869
1091
|
softapTrace("softap_step_done", {step, durationMs: Date.now() - startedAt})
|
|
870
1092
|
this.setStep(step, {status: "done", durationMs: Date.now() - startedAt})
|
|
871
1093
|
}
|
|
@@ -917,7 +1139,7 @@ export function createSoftapCallDeps(args: {
|
|
|
917
1139
|
*/
|
|
918
1140
|
video?: {width: number; height: number; fps: number; maxBitrateBps: number}
|
|
919
1141
|
/** Resolves when the meeting reports a frame reached ACS; rejects on a failed feed. */
|
|
920
|
-
awaitFirstFrame: () => Promise<void>
|
|
1142
|
+
awaitFirstFrame: (report?: SoftapStepReporter, options?: {fresh?: boolean}) => Promise<void>
|
|
921
1143
|
/**
|
|
922
1144
|
* Mid-call camera recovery. Resolves `true` only when ingest is live again.
|
|
923
1145
|
* A standing `failed` is the reason we are republishing, so it must not abort the wait.
|
|
@@ -951,9 +1173,16 @@ export function createSoftapCallDeps(args: {
|
|
|
951
1173
|
traceId: string
|
|
952
1174
|
captureAudio?: boolean
|
|
953
1175
|
video?: SoftapVideoPolicy
|
|
1176
|
+
mediaGeneration?: number
|
|
954
1177
|
},
|
|
955
1178
|
) => Promise<unknown>
|
|
956
1179
|
stopPublishing: (packageName: string) => Promise<void>
|
|
1180
|
+
/** Drop a deferred BLE stopStream that belonged to a destroyed SoftAP media generation. */
|
|
1181
|
+
discardPendingBleStop?: () => void
|
|
1182
|
+
/**
|
|
1183
|
+
* Rebind WHIP ingest on the standing ACS session. Required for media-only rebuilds.
|
|
1184
|
+
*/
|
|
1185
|
+
rebindIngest?: (report?: SoftapStepReporter) => Promise<{ingestUrl: string}>
|
|
957
1186
|
/**
|
|
958
1187
|
* Whether the host is taking the wearer's voice off the glasses over BLE LC3 for this call.
|
|
959
1188
|
*
|
|
@@ -1013,11 +1242,19 @@ export function createSoftapCallDeps(args: {
|
|
|
1013
1242
|
return await enable()
|
|
1014
1243
|
} catch (error) {
|
|
1015
1244
|
if (error instanceof Error && /no password/.test(error.message)) throw error
|
|
1245
|
+
const unanswered = isHotspotAnswerTimeout(error)
|
|
1016
1246
|
// Cancel-then-start races the previous disable: the glasses report disabled (or no SSID)
|
|
1017
1247
|
// and the UI said "Couldn't start glasses hotspot" before step 2 ran on a leftover AP.
|
|
1018
1248
|
softapTraceFailure("hotspot_enable_retry", {
|
|
1019
1249
|
reason: error instanceof Error ? error.message : String(error),
|
|
1250
|
+
unanswered,
|
|
1020
1251
|
})
|
|
1252
|
+
if (unanswered) {
|
|
1253
|
+
// Enable is idempotent; disabling here is what turned a late-delivered enable back off
|
|
1254
|
+
// and cost the whole rebuild budget after a walk-away.
|
|
1255
|
+
report?.("Glasses did not answer; asking again without turning the hotspot off")
|
|
1256
|
+
return await enable()
|
|
1257
|
+
}
|
|
1021
1258
|
report?.("Glasses hotspot did not start; turning it off and trying again")
|
|
1022
1259
|
await subsystems.setHotspotState(false)
|
|
1023
1260
|
if (hotspotBroadcastWaitMs > 0) {
|
|
@@ -1115,7 +1352,8 @@ export function createSoftapCallDeps(args: {
|
|
|
1115
1352
|
},
|
|
1116
1353
|
leaveMeeting: () => subsystems.leaveMeeting(packageName),
|
|
1117
1354
|
...(subsystems.endMeeting ? {endMeeting: () => subsystems.endMeeting!(packageName)} : {}),
|
|
1118
|
-
|
|
1355
|
+
...(subsystems.rebindIngest ? {rebindIngest: subsystems.rebindIngest} : {}),
|
|
1356
|
+
startPublishing: async ({ingestUrl, traceId, mediaGeneration}, report) => {
|
|
1119
1357
|
// Narrate the glasses side while the BLE start command is in flight. `initializing` means
|
|
1120
1358
|
// the glasses accepted the command and are opening the camera; `streaming` means the WHIP
|
|
1121
1359
|
// offer was answered and ICE connected; anything else is the reason it did not.
|
|
@@ -1162,13 +1400,17 @@ export function createSoftapCallDeps(args: {
|
|
|
1162
1400
|
ice: {stun: ""},
|
|
1163
1401
|
traceId,
|
|
1164
1402
|
captureAudio: !lc3Uplink,
|
|
1403
|
+
...(typeof mediaGeneration === "number" ? {mediaGeneration} : {}),
|
|
1165
1404
|
...(policy ? {video: policy} : {}),
|
|
1166
1405
|
})
|
|
1167
1406
|
} finally {
|
|
1168
1407
|
unsubscribe?.()
|
|
1169
1408
|
}
|
|
1170
1409
|
},
|
|
1171
|
-
stopPublishing: () =>
|
|
1410
|
+
stopPublishing: async () => {
|
|
1411
|
+
await subsystems.stopPublishing(packageName)
|
|
1412
|
+
subsystems.discardPendingBleStop?.()
|
|
1413
|
+
},
|
|
1172
1414
|
awaitFirstFrame: args.awaitFirstFrame,
|
|
1173
1415
|
waitUntilLive: args.waitUntilLive,
|
|
1174
1416
|
}
|