@mentra/bluetooth-sdk 3.2.1-dev.314 → 3.2.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.
Files changed (133) hide show
  1. package/README.md +4 -4
  2. package/android/src/debug/java/com/mentra/bluetoothsdk/sgcs/NimoDiagnostics.kt +368 -0
  3. package/android/src/debug/java/com/mentra/bluetoothsdk/sgcs/NimoFramebufferProtocol.kt +156 -0
  4. package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkDefaults.kt +3 -0
  5. package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkModule.kt +6 -0
  6. package/android/src/main/java/com/mentra/bluetoothsdk/Bridge.kt +2 -1
  7. package/android/src/main/java/com/mentra/bluetoothsdk/DeviceManager.kt +129 -25
  8. package/android/src/main/java/com/mentra/bluetoothsdk/DeviceStore.kt +37 -13
  9. package/android/src/main/java/com/mentra/bluetoothsdk/GeneratedChangelogCatalog.kt +2 -2
  10. package/android/src/main/java/com/mentra/bluetoothsdk/GeneratedReleaseMetadata.kt +5 -5
  11. package/android/src/main/java/com/mentra/bluetoothsdk/MentraBluetoothSdk.kt +4 -0
  12. package/android/src/main/java/com/mentra/bluetoothsdk/VersionInfoResponseAccumulator.kt +13 -2
  13. package/android/src/main/java/com/mentra/bluetoothsdk/requests/DisplayRequests.kt +1 -1
  14. package/android/src/main/java/com/mentra/bluetoothsdk/services/Foreground.kt +33 -0
  15. package/android/src/main/java/com/mentra/bluetoothsdk/services/G2ConnectionRecovery.kt +47 -0
  16. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/G1.kt +2 -2
  17. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/G2.kt +72 -21
  18. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraLive.kt +101 -22
  19. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraNex.kt +12 -8
  20. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/Nimo.kt +314 -395
  21. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/NimoCanvasCodec.kt +245 -0
  22. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/NimoCanvasCoordinator.kt +238 -0
  23. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/NimoCanvasEncoder.kt +58 -0
  24. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/NimoCanvasSession.kt +180 -0
  25. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/SGCManager.kt +11 -0
  26. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/SelectedDeviceAddress.kt +17 -0
  27. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/SerializedGattCallback.kt +5 -0
  28. package/android/src/main/java/com/mentra/bluetoothsdk/utils/G2Text.kt +7 -0
  29. package/android/src/release/java/com/mentra/bluetoothsdk/sgcs/NimoDiagnostics.kt +20 -0
  30. package/android/src/test/java/com/mentra/bluetoothsdk/DashboardContentFormatterTest.kt +2 -2
  31. package/android/src/test/java/com/mentra/bluetoothsdk/DeviceIdentityPairingTest.kt +59 -0
  32. package/android/src/test/java/com/mentra/bluetoothsdk/DeviceManagerSceneHandoffTest.kt +666 -0
  33. package/android/src/test/java/com/mentra/bluetoothsdk/G2TextTest.kt +13 -0
  34. package/android/src/test/java/com/mentra/bluetoothsdk/VersionInfoResponseAccumulatorTest.kt +71 -0
  35. package/android/src/test/java/com/mentra/bluetoothsdk/services/G2ConnectionRecoveryTest.kt +51 -0
  36. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/G1PairingTest.kt +105 -0
  37. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/G2DisplayThreadTest.kt +120 -0
  38. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/G2PairingTargetTest.kt +90 -0
  39. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/MentraLiveFileTransferSessionTest.kt +55 -0
  40. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/MentraLiveGattCallbackTest.kt +33 -0
  41. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/MentraLivePairingTargetTest.kt +65 -0
  42. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/MentraNexPairingTest.kt +151 -0
  43. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/NimoBitmapAcceptanceTest.kt +105 -0
  44. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/NimoCanvasCodecTest.kt +220 -0
  45. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/NimoCanvasCoordinatorTest.kt +348 -0
  46. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/NimoCanvasSessionTest.kt +339 -0
  47. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/NimoMicReconnectTest.kt +93 -0
  48. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/NimoProtocolTest.kt +11 -0
  49. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/SelectedDeviceAddressTest.kt +27 -0
  50. package/android/src/testDebug/java/com/mentra/bluetoothsdk/sgcs/NimoDiagnosticsTest.kt +287 -0
  51. package/android/src/testDebug/java/com/mentra/bluetoothsdk/sgcs/NimoFramebufferProtocolTest.kt +127 -0
  52. package/android/src/testDebug/java/com/mentra/bluetoothsdk/sgcs/NimoLifecycleTest.kt +288 -0
  53. package/android/src/testDebug/java/com/mentra/bluetoothsdk/sgcs/NimoReadinessDriverTest.kt +205 -0
  54. package/build/BluetoothSdk.types.d.ts +13 -0
  55. package/build/BluetoothSdk.types.d.ts.map +1 -1
  56. package/build/BluetoothSdk.types.js +1 -0
  57. package/build/BluetoothSdk.types.js.map +1 -1
  58. package/build/_private/BluetoothSdkModule.d.ts +4 -0
  59. package/build/_private/BluetoothSdkModule.d.ts.map +1 -1
  60. package/build/_private/BluetoothSdkModule.js +3 -0
  61. package/build/_private/BluetoothSdkModule.js.map +1 -1
  62. package/build/generated/changelogCatalog.d.ts +2 -2
  63. package/build/generated/changelogCatalog.js +2 -2
  64. package/build/generated/changelogCatalog.js.map +1 -1
  65. package/build/generated/releaseMetadata.js +5 -5
  66. package/build/generated/releaseMetadata.js.map +1 -1
  67. package/build/index.d.ts.map +1 -1
  68. package/build/index.js +1 -0
  69. package/build/index.js.map +1 -1
  70. package/ios/BluetoothSdkModule.swift +9 -0
  71. package/ios/MentraBluetoothSDK.podspec +3 -2
  72. package/ios/Source/BluetoothBackgroundMode.swift +32 -0
  73. package/ios/Source/BluetoothSdkDefaults.swift +6 -1
  74. package/ios/Source/Bridge.swift +15 -2
  75. package/ios/Source/DeviceManager.swift +96 -63
  76. package/ios/Source/DeviceStore.swift +26 -7
  77. package/ios/Source/GeneratedChangelogCatalog.swift +2 -2
  78. package/ios/Source/GeneratedReleaseMetadata.swift +5 -5
  79. package/ios/Source/MentraBluetoothSDK.swift +18 -0
  80. package/ios/Source/audio/GlassesMicWatchdog.swift +33 -0
  81. package/ios/Source/requests/DisplayRequests.swift +1 -1
  82. package/ios/Source/requests/VersionInfoResponseAccumulator.swift +11 -1
  83. package/ios/Source/sgcs/Ar99.swift +1 -2
  84. package/ios/Source/sgcs/G1.swift +68 -38
  85. package/ios/Source/sgcs/G1ConnectionTarget.swift +13 -0
  86. package/ios/Source/sgcs/G2.swift +261 -247
  87. package/ios/Source/sgcs/G2ConnectionSession.swift +101 -0
  88. package/ios/Source/sgcs/Mach1.swift +19 -7
  89. package/ios/Source/sgcs/MentraLive.swift +201 -156
  90. package/ios/Source/sgcs/MentraLiveFileTransferSession.swift +137 -0
  91. package/ios/Source/sgcs/MentraLivePairingAdvertisement.swift +47 -0
  92. package/ios/Source/sgcs/MentraNex.swift +8 -31
  93. package/ios/Source/sgcs/NexConnectionTarget.swift +15 -0
  94. package/ios/Source/sgcs/Nimo.swift +280 -408
  95. package/ios/Source/sgcs/NimoCanvasCodec.swift +269 -0
  96. package/ios/Source/sgcs/NimoCanvasCoordinator.swift +233 -0
  97. package/ios/Source/sgcs/NimoCanvasImage.swift +53 -0
  98. package/ios/Source/sgcs/NimoCanvasSession.swift +166 -0
  99. package/ios/Source/sgcs/NimoDiscovery.swift +49 -0
  100. package/ios/Source/sgcs/SGCManager.swift +24 -0
  101. package/ios/Source/sgcs/VuzixConnectionTarget.swift +15 -0
  102. package/ios/Source/status/DeviceStatus.swift +1 -0
  103. package/ios/Source/stt/SherpaOnnxTranscriber.swift +49 -189
  104. package/ios/Source/utils/DemandDrivenAudioWorker.swift +150 -0
  105. package/ios/Source/utils/G2Text.swift +11 -0
  106. package/ios/Tests/BluetoothBackgroundModeTests.swift +32 -0
  107. package/ios/Tests/DashboardContentFormatterTests.swift +2 -2
  108. package/ios/Tests/DashboardRenderingTests.swift +31 -0
  109. package/ios/Tests/DemandDrivenAudioWorkerTests.swift +157 -0
  110. package/ios/Tests/DeviceIdentityPairingTests.swift +49 -0
  111. package/ios/Tests/DeviceManagerReconnectTests.swift +362 -0
  112. package/ios/Tests/G1PairingTests.swift +162 -0
  113. package/ios/Tests/G2ConnectionSessionTests.swift +172 -0
  114. package/ios/Tests/G2TextTests.swift +10 -0
  115. package/ios/Tests/MentraLiveFileTransferSessionTests.swift +47 -0
  116. package/ios/Tests/MentraLiveReconnectPolicyTests.swift +58 -0
  117. package/ios/Tests/NimoCanvasCodecTests.swift +204 -0
  118. package/ios/Tests/NimoCanvasSessionTests.swift +181 -0
  119. package/ios/Tests/NimoCanvasTransportTests.swift +186 -0
  120. package/ios/Tests/NimoDeviceCommandsTests.swift +16 -0
  121. package/ios/Tests/NimoDiscoveryTests.swift +79 -0
  122. package/ios/Tests/SGCConnectionTargetTests.swift +48 -0
  123. package/ios/Tests/VersionInfoCommandTests.swift +53 -0
  124. package/ios/Tests/VersionInfoResponseAccumulatorTests.swift +65 -0
  125. package/ios/Tests/VideoRecordingCommandTests.swift +81 -0
  126. package/package.json +2 -2
  127. package/plugin/build/withIos.d.ts +2 -0
  128. package/plugin/build/withIos.js +12 -1
  129. package/src/BluetoothSdk.types.ts +14 -0
  130. package/src/_private/BluetoothSdkModule.ts +8 -0
  131. package/src/generated/changelogCatalog.ts +2 -2
  132. package/src/generated/releaseMetadata.ts +5 -5
  133. package/src/index.ts +1 -0
@@ -318,9 +318,9 @@ struct ViewState {
318
318
  var lc3Converter: PcmConverter?
319
319
  /// Audio output format - defaults to LC3 for bandwidth savings
320
320
  private var audioOutputFormat: AudioOutputFormat = .lc3
321
- /// Last time we received an LC3 frame from the glasses (used by the mic
322
- /// inactivity watchdog).
323
- private var lastLc3Event: Date?
321
+ private var micWatchdog = GlassesMicWatchdog()
322
+ // Injectable monotonic clock so recovery deadlines can be tested without sleeping.
323
+ var micWatchdogNow: () -> TimeInterval = { ProcessInfo.processInfo.systemUptime }
324
324
  private var sequenceGapEvents: Int64 = 0
325
325
  private var decodeFailures: Int64 = 0
326
326
  private var lastLc3ReceivedAt: Int64?
@@ -330,7 +330,7 @@ struct ViewState {
330
330
 
331
331
  /// STT:
332
332
  #if !os(macOS) && (!SWIFT_PACKAGE || MENTRA_FEATURE_LOCAL_STT)
333
- private var transcriber: SherpaOnnxTranscriber?
333
+ private let transcriber = SherpaOnnxTranscriber()
334
334
  #endif
335
335
 
336
336
  var viewStates: [ViewState] = [
@@ -383,24 +383,6 @@ struct ViewState {
383
383
  // Start memory monitoring (logs every 30s to help detect leaks)
384
384
  // MemoryMonitor.start()
385
385
 
386
- // Initialize SherpaOnnx Transcriber
387
- #if !os(macOS) && (!SWIFT_PACKAGE || MENTRA_FEATURE_LOCAL_STT)
388
- if let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene,
389
- let window = windowScene.windows.first,
390
- let rootViewController = window.rootViewController
391
- {
392
- transcriber = SherpaOnnxTranscriber(context: rootViewController)
393
- } else {
394
- Bridge.log("Failed to create SherpaOnnxTranscriber - no root view controller found")
395
- }
396
-
397
- // Initialize the transcriber
398
- if let transcriber = transcriber {
399
- transcriber.initialize()
400
- Bridge.log("SherpaOnnxTranscriber fully initialized")
401
- }
402
- #endif
403
-
404
386
  // Initialize persistent LC3 converter for unified audio encoding
405
387
  lc3Converter = PcmConverter()
406
388
  Bridge.log("LC3 converter initialized for unified audio encoding")
@@ -472,10 +454,17 @@ struct ViewState {
472
454
  }
473
455
 
474
456
  func reportGlassesAudioActivity() {
475
- lastLc3Event = Date()
457
+ micWatchdog.receivedAudio(at: micWatchdogNow())
476
458
  lastPcmProducedAt = nowMs()
477
459
  }
478
460
 
461
+ /// Decoded glasses audio must update recovery health; phone PCM must not.
462
+ func handleGlassesPcm(_ pcmData: Data) {
463
+ guard !pcmData.isEmpty else { return }
464
+ reportGlassesAudioActivity()
465
+ handlePcm(pcmData)
466
+ }
467
+
479
468
  private func micHealth() -> MicHealth {
480
469
  MicHealth(
481
470
  sequenceGapEvents: sequenceGapEvents,
@@ -499,13 +488,13 @@ struct ViewState {
499
488
  lastLc3ReceivedAt = nil
500
489
  lastPcmProducedAt = nil
501
490
  lastLc3Sequence = nil
502
- lastLc3Event = nil
491
+ micWatchdog = GlassesMicWatchdog()
503
492
  }
504
493
 
505
494
  private func recordLc3Packet(sequenceNumber: Int?) {
506
495
  let receivedAt = nowMs()
507
496
  lastLc3ReceivedAt = receivedAt
508
- lastLc3Event = Date()
497
+ micWatchdog.receivedAudio(at: micWatchdogNow())
509
498
  var hasGap = false
510
499
  if let sequenceNumber {
511
500
  let normalized = sequenceNumber & 0xFF
@@ -535,7 +524,7 @@ struct ViewState {
535
524
  // Send PCM to local transcriber.
536
525
  #if !os(macOS) && (!SWIFT_PACKAGE || MENTRA_FEATURE_LOCAL_STT)
537
526
  if shouldSendTranscript || localSttFallbackActive {
538
- transcriber?.acceptAudio(pcm16le: pcmData)
527
+ transcriber.acceptAudio(pcm16le: pcmData)
539
528
  }
540
529
  #endif
541
530
  }
@@ -605,6 +594,7 @@ struct ViewState {
605
594
  }
606
595
 
607
596
  currentMic = micUsed
597
+ updateGlassesMicExpectation()
608
598
 
609
599
  // log if no mic was found:
610
600
  if micUsed == "" && micEnabled {
@@ -723,6 +713,7 @@ struct ViewState {
723
713
  Bridge.log("MAN: Manager already initialized, cleaning up previous sgc")
724
714
  sgc?.cleanup()
725
715
  sgc = nil
716
+ DeviceStore.shared.apply("glasses", "micEnabled", false)
726
717
  resetSystemTimeSync()
727
718
  }
728
719
 
@@ -902,27 +893,20 @@ struct ViewState {
902
893
  return result
903
894
  }
904
895
 
905
- private func checkAndReinitGlassesMic() {
906
- // if the glasses mic is marked as enabled (and the glasses are connected), but our last known lc3 event is from > 5 seconds ago, reinitialize the mic:
907
- let glassesMicEnabled = DeviceStore.shared.get("glasses", "micEnabled") as? Bool ?? false
908
- let glassesConnected = DeviceStore.shared.get("glasses", "connected") as? Bool ?? false
909
- if !glassesMicEnabled || !glassesConnected {
910
- return
911
- }
912
-
913
- if sgc?.isMicSuspendedForAudio == true {
914
- Bridge.log("MAN: Glasses mic intentionally suspended for phone audio; skipping mic recovery")
915
- return
916
- }
917
-
918
- if PhoneAudioMonitor.getInstance().isOwnAppAudioPlaying() {
919
- Bridge.log("MAN: Mentra audio is playing; skipping glasses mic recovery")
920
- return
921
- }
896
+ private func updateGlassesMicExpectation() {
897
+ // Demand/route and device readiness decide whether audio is expected. The
898
+ // shared micEnabled flag alone is not evidence of a running hardware stream.
899
+ let expected = micEnabled && currentMic == MicTypes.GLASSES_CUSTOM
900
+ && sgc?.fullyBooted == true && sgc?.hasMic == true
901
+ && sgc?.isMicSuspendedForAudio != true
902
+ && !PhoneAudioMonitor.getInstance().isOwnAppAudioPlaying()
903
+ micWatchdog.expectAudio(expected, at: micWatchdogNow())
904
+ }
922
905
 
923
- let timeSinceLastLc3Event = Date().timeIntervalSince(lastLc3Event ?? Date())
924
- if timeSinceLastLc3Event > 5 {
925
- Bridge.log("MAN: No audio activity in the last 5 seconds from glasses, reinitializing glasses mic")
906
+ func checkAndReinitGlassesMic() {
907
+ updateGlassesMicExpectation()
908
+ if micWatchdog.shouldRetry(at: micWatchdogNow()) {
909
+ Bridge.log("MAN: Expected glasses audio missing for 5 seconds; retrying mic start")
926
910
  sgc?.setMicEnabled(true)
927
911
  }
928
912
  }
@@ -982,7 +966,7 @@ struct ViewState {
982
966
  func restartTranscriber() {
983
967
  #if !os(macOS) && (!SWIFT_PACKAGE || MENTRA_FEATURE_LOCAL_STT)
984
968
  Bridge.log("MAN: Restarting SherpaOnnxTranscriber via command")
985
- transcriber?.restart()
969
+ transcriber.restart()
986
970
  #else
987
971
  Bridge.log("MAN: Local STT is not included in this SwiftPM build")
988
972
  #endif
@@ -998,12 +982,16 @@ struct ViewState {
998
982
  Bridge.log("MAN: handleDeviceReady(): \(sgc.type)")
999
983
  resetMicHealth()
1000
984
 
985
+ // A new identity must never inherit the previous glasses' address when
986
+ // pairing supplied only a name. Same-device reconnects retain their cache.
987
+ let identityChanged = !pendingDeviceName.isEmpty &&
988
+ (pendingDeviceName != deviceName || sgc.type != defaultWearable)
989
+ if identityChanged || !pendingDeviceAddress.isEmpty {
990
+ deviceAddress = pendingDeviceAddress
991
+ }
1001
992
  if !pendingDeviceName.isEmpty {
1002
993
  deviceName = pendingDeviceName
1003
994
  }
1004
- if !pendingDeviceAddress.isEmpty {
1005
- deviceAddress = pendingDeviceAddress
1006
- }
1007
995
  clearPendingConnection()
1008
996
  defaultWearable = sgc.type
1009
997
  searching = false
@@ -1020,18 +1008,22 @@ struct ViewState {
1020
1008
  let h = DeviceStore.shared.get("bluetooth", "dashboard_height") as? Int ?? 4
1021
1009
  // Fall back to the canonical default (2), matching DeviceStore, not 1.
1022
1010
  let rawDepth = DeviceStore.shared.get("bluetooth", "dashboard_depth") as? Int ?? 2
1023
- let d = min(max(rawDepth, 1), 4)
1011
+ let d = sgc.type == DeviceTypes.NIMO ? min(max(rawDepth, 0), 10) : min(max(rawDepth, 1), 4)
1024
1012
  sgc.setDashboardPosition(h, d)
1025
1013
  }
1026
1014
 
1027
- // Show welcome message on first connect for all display glasses
1015
+ // Preserve the connected-edge scene replay on full-frame adapters.
1028
1016
  if shouldSendBootingMessage {
1029
- Task {
1030
- await sgc.sendTextWall("// MentraOS Connected")
1031
- try? await Task.sleep(nanoseconds: 3_000_000_000) // 1 second
1032
- sgc.clearDisplay()
1033
- }
1034
1017
  shouldSendBootingMessage = false
1018
+ if sgc.showConnectionConfirmation {
1019
+ Task {
1020
+ guard (self.sgc as AnyObject?) === (sgc as AnyObject) else { return }
1021
+ await sgc.sendTextWall("// MentraOS Connected")
1022
+ try? await Task.sleep(nanoseconds: 3_000_000_000)
1023
+ guard (self.sgc as AnyObject?) === (sgc as AnyObject) else { return }
1024
+ sgc.clearDisplay()
1025
+ }
1026
+ }
1035
1027
  }
1036
1028
 
1037
1029
  // Call device-specific setup handlers
@@ -1058,6 +1050,11 @@ struct ViewState {
1058
1050
  #endif
1059
1051
  checkCurrentAudioDevice()
1060
1052
 
1053
+ // Disconnect clears micEnabled but preserves the consumers' audio requests.
1054
+ // Recompute demand before selecting a microphone; unchanged requests are
1055
+ // deduplicated by DeviceStore.apply().
1056
+ setMicState()
1057
+
1061
1058
  // save the default_wearable now that we're connected:
1062
1059
  Bridge.saveSetting("default_wearable", defaultWearable)
1063
1060
  Bridge.saveSetting("device_name", deviceName)
@@ -1214,7 +1211,9 @@ struct ViewState {
1214
1211
  self.dashboardSceneCleanupPending = false
1215
1212
  let elementIds = Array(self.pendingDashboardSceneElementIds)
1216
1213
  self.pendingDashboardSceneElementIds.removeAll()
1217
- await self.sgc?.clearSceneElements(elementIds)
1214
+ if self.sgc?.sceneHandoffRequiresClear == true {
1215
+ await self.sgc?.clearSceneElements(elementIds)
1216
+ }
1218
1217
  }
1219
1218
  self.dashboardSceneCleanupTask = nil
1220
1219
  }
@@ -1261,7 +1260,7 @@ struct ViewState {
1261
1260
  if stateIndex == 1, dashboardSceneCleanupDeferred {
1262
1261
  dashboardSceneCleanupPending = true
1263
1262
  pendingDashboardSceneElementIds.formUnion(prevFrame.elements.map(\.id))
1264
- } else if layoutType != "clear_view" {
1263
+ } else if layoutType != "clear_view", shouldClearSceneHandoff(stateIndex) {
1265
1264
  let ids = prevFrame.elements.map(\.id)
1266
1265
  Task { [weak self] in
1267
1266
  await self?.sgc?.clearSceneElements(ids)
@@ -1355,7 +1354,7 @@ struct ViewState {
1355
1354
  // on G2 - no page rebuild).
1356
1355
  let prevLegacyType = viewStates[stateIndex].layoutType
1357
1356
  let cleanupDeferred = stateIndex == 1 && dashboardSceneCleanupDeferred
1358
- if !cleanupDeferred,
1357
+ if !cleanupDeferred, shouldClearSceneHandoff(stateIndex),
1359
1358
  !prevLegacyType.isEmpty,
1360
1359
  prevLegacyType != "clear_view",
1361
1360
  prevLegacyType != "scene"
@@ -1371,7 +1370,7 @@ struct ViewState {
1371
1370
  if stateIndex == 1, dashboardSceneCleanupDeferred {
1372
1371
  dashboardSceneCleanupPending = true
1373
1372
  pendingDashboardSceneElementIds.formUnion(prevFrame.elements.map(\.id))
1374
- } else {
1373
+ } else if shouldClearSceneHandoff(stateIndex) {
1375
1374
  let ids = prevFrame.elements.map(\.id)
1376
1375
  Task { [weak self] in
1377
1376
  await self?.sgc?.clearSceneElements(ids)
@@ -1395,6 +1394,13 @@ struct ViewState {
1395
1394
  }
1396
1395
  }
1397
1396
 
1397
+ private func shouldClearSceneHandoff(_ stateIndex: Int) -> Bool {
1398
+ let visibleIndex = headUp && contextualDashboard ? 1 : 0
1399
+ guard let sgc else { return false }
1400
+ return stateIndex == visibleIndex && !screenDisabled && sgc.fullyBooted
1401
+ && !sgc.type.contains(DeviceTypes.SIMULATED) && sgc.sceneHandoffRequiresClear
1402
+ }
1403
+
1398
1404
  /// Guarded scene dispatch - mirrors sendCurrentState's send conditions.
1399
1405
  private func dispatchSceneFrame(_ frame: SceneFrame, stateIndex: Int) {
1400
1406
  if screenDisabled { return }
@@ -1738,6 +1744,9 @@ struct ViewState {
1738
1744
  func setMicState() {
1739
1745
  let willSendPcm = shouldSendPcm || shouldSendLc3
1740
1746
  let willSendTranscript = shouldSendTranscript || localSttFallbackActive
1747
+ #if !os(macOS) && (!SWIFT_PACKAGE || MENTRA_FEATURE_LOCAL_STT)
1748
+ transcriber.setActive(willSendTranscript)
1749
+ #endif
1741
1750
  micEnabled = willSendPcm || willSendTranscript
1742
1751
  updateMicState()
1743
1752
  }
@@ -1943,6 +1952,9 @@ struct ViewState {
1943
1952
  sgc?.clearDisplay() // clear the screen
1944
1953
  sgc?.disconnect()
1945
1954
  sgc = nil // Clear the SGC reference after disconnect
1955
+ // This cache belongs to the discarded connection. Keep consumer demand,
1956
+ // but require a new mic-enable command when replacement glasses are ready.
1957
+ DeviceStore.shared.apply("glasses", "micEnabled", false)
1946
1958
  resetSystemTimeSync()
1947
1959
  resetMicHealth()
1948
1960
  searching = false
@@ -1987,6 +1999,28 @@ struct ViewState {
1987
1999
  controller = nil // Clear the controller reference after disconnect
1988
2000
  }
1989
2001
 
2002
+ private var unpairInProgress = false
2003
+
2004
+ /// Explicit user Unpair, separate from passive pairing cleanup and logout.
2005
+ func unpair() async throws {
2006
+ guard !unpairInProgress else {
2007
+ throw NSError(domain: "NimoUnpair", code: 1,
2008
+ userInfo: [NSLocalizedDescriptionKey: "Unpair already in progress"])
2009
+ }
2010
+ unpairInProgress = true
2011
+ defer { unpairInProgress = false }
2012
+ if let nimo = sgc as? Nimo {
2013
+ _ = await withCheckedContinuation { (continuation: CheckedContinuation<Bool, Never>) in
2014
+ nimo.resetForUnpair { continuation.resume(returning: $0) }
2015
+ }
2016
+ guard (sgc as? Nimo) === nimo else {
2017
+ throw NSError(domain: "NimoUnpair", code: 2,
2018
+ userInfo: [NSLocalizedDescriptionKey: "Glasses changed during Unpair"])
2019
+ }
2020
+ }
2021
+ forget()
2022
+ }
2023
+
1990
2024
  func forget() {
1991
2025
  Bridge.log("MAN: Forgetting smart glasses")
1992
2026
  // Call forget first to stop timers/handlers/reconnect logic
@@ -2053,8 +2087,7 @@ struct ViewState {
2053
2087
  #endif
2054
2088
  // Clean up transcriber resources
2055
2089
  #if !os(macOS) && (!SWIFT_PACKAGE || MENTRA_FEATURE_LOCAL_STT)
2056
- transcriber?.shutdown()
2057
- transcriber = nil
2090
+ transcriber.shutdown()
2058
2091
  #endif
2059
2092
 
2060
2093
  // Clean up LC3 converter
@@ -94,6 +94,7 @@ class DeviceStore {
94
94
  store.set("bluetooth", "gallery_mode", true)
95
95
  store.set("bluetooth", "voice_activity_detection_enabled", BluetoothSdkDefaults.voiceActivityDetectionEnabled)
96
96
  store.set("bluetooth", "loudness_gate_enabled", BluetoothSdkDefaults.loudnessGateEnabled)
97
+ store.set("bluetooth", "auto_power_off_enabled", BluetoothSdkDefaults.autoPowerOffEnabled)
97
98
  // Mentra Nex feature flag (off by default; toggled from Nex Developer Settings):
98
99
  store.set("bluetooth", "nex_chinese_captions", false)
99
100
  store.set("bluetooth", "screen_disabled", false)
@@ -191,14 +192,25 @@ class DeviceStore {
191
192
 
192
193
  // BLUETOOTH:
193
194
 
195
+ case ("bluetooth", "contextual_dashboard"):
196
+ if value is Bool {
197
+ Task { @MainActor in
198
+ if self.store.get("glasses", "headUp") as? Bool == true {
199
+ DeviceManager.shared.sendCurrentState()
200
+ }
201
+ }
202
+ }
203
+
194
204
  case ("bluetooth", "brightness"):
195
205
  let b = value as? Int ?? 50
196
206
  let auto = store.get("bluetooth", "auto_brightness") as? Bool ?? true
197
207
  Task {
198
- DeviceManager.shared.sgc?.setBrightness(b, autoMode: auto)
199
- await DeviceManager.shared.sgc?.sendTextWall("Set brightness to \(b)%")
208
+ guard let device = DeviceManager.shared.sgc else { return }
209
+ device.setBrightness(b, autoMode: auto)
210
+ guard device.showBrightnessConfirmation else { return }
211
+ await device.sendTextWall("Set brightness to \(b)%")
200
212
  try? await Task.sleep(nanoseconds: 800_000_000) // 0.8 seconds
201
- DeviceManager.shared.sgc?.clearDisplay()
213
+ if (DeviceManager.shared.sgc as AnyObject?) === (device as AnyObject) { device.clearDisplay() }
202
214
  }
203
215
 
204
216
  case ("bluetooth", "auto_brightness"):
@@ -206,13 +218,14 @@ class DeviceStore {
206
218
  let auto = value as? Bool ?? true
207
219
  let autoBrightnessChanged = (oldValue as? Bool) != auto
208
220
  Task {
209
- DeviceManager.shared.sgc?.setBrightness(b, autoMode: auto)
210
- if autoBrightnessChanged {
211
- await DeviceManager.shared.sgc?.sendTextWall(
221
+ guard let device = DeviceManager.shared.sgc else { return }
222
+ device.setBrightness(b, autoMode: auto)
223
+ if autoBrightnessChanged, device.showBrightnessConfirmation {
224
+ await device.sendTextWall(
212
225
  auto ? "Enabled auto brightness" : "Disabled auto brightness"
213
226
  )
214
227
  try? await Task.sleep(nanoseconds: 800_000_000) // 0.8 seconds
215
- DeviceManager.shared.sgc?.clearDisplay()
228
+ if (DeviceManager.shared.sgc as AnyObject?) === (device as AnyObject) { device.clearDisplay() }
216
229
  }
217
230
  }
218
231
 
@@ -254,6 +267,12 @@ class DeviceStore {
254
267
  case ("bluetooth", "loudness_gate_enabled"):
255
268
  DeviceManager.shared.sgc?.sendLoudnessGateSetting()
256
269
 
270
+ case ("bluetooth", "auto_power_off_enabled"):
271
+ Bridge.log(
272
+ "DeviceStore: auto_power_off_enabled changed to \(String(describing: value)) — sending cs_swit type 11"
273
+ )
274
+ DeviceManager.shared.sgc?.sendAutoPowerOffSetting()
275
+
257
276
  // Deliberately has no seeded default: the key starts absent so that a
258
277
  // session where the engine has not authorized tuning can only ever
259
278
  // send a reset.
@@ -2,8 +2,8 @@ import Foundation
2
2
 
3
3
  /// Generated from /changelogs. Do not edit directly.
4
4
  let generatedReleaseChangelogs: [ReleaseChangelog] = [
5
- ReleaseChangelog(version: "3.2.1", markdown: "This release is under active development. User-facing changes will be documented as they land."),
6
- ReleaseChangelog(version: "3.2.0", markdown: "This release is under active development. User-facing changes will be documented as they land.\n\n- Breaking Bluetooth SDK change: `forgetWifiNetwork(ssid)` now returns `WifiForgetResult` instead of `WifiStatusChangeEvent`. Migrate status-only consumers to the semantic `outcome`; the requested network remains `ssid`, while optional post-command connectivity is reported by `connected`, `currentSsid`, and `localIp`."),
5
+ ReleaseChangelog(version: "3.2.1", markdown: "- **Livestreamer:** \"Stream Here\" now works without a Wi-Fi network. Improved stream startup and connection reliability.\n- **Photos and gallery:** More reliable photo capture and gallery syncing.\n- **Mentra AI:** Reliability improvements and bug fixes.\n- **Miniapps:** Smoother transitions, with back buttons and gestures that take you to the previous screen inside a miniapp.\n- **Pairing and connections:** Improved G1 pairing and G2 background reconnection.\n- **Glasses updates:** Fixed repeated update checks and improved recovery after interrupted updates.\n- **Mentra Maps:** Bug fixes.\n- **NIMO:** Improved text and map layouts.\n- **Self-hosting:** Connect the Mentra App to your own self-hosted Mentra backend."),
6
+ ReleaseChangelog(version: "3.2.0", markdown: "- Improved glasses connectivity and Wi-Fi setup."),
7
7
  ReleaseChangelog(version: "3.1.1", markdown: "- Improved Google login on iOS.\n- Pairing no longer requires phone microphone access."),
8
8
  ReleaseChangelog(version: "3.1.0", markdown: "**Mentra App and glasses**\n\n- Improved glasses pairing and reconnection, including on iPad.\n- Improved Android background reliability for captions and miniapps.\n- Added Xingyi AR99 support.\n- More reliable photo and video capture, with improved shutter sounds and privacy-light timing. Taking photos no longer interrupts your running miniapp.\n- Added video thumbnails and smoother gallery browsing.\n- Easier Mentra Live updates through your phone, with clearer progress and better recovery from interruptions.\n\n**Miniapp updates**\n\n- **Mentra AI:** Control whether photos are included with questions. Improved visual answers and connected-app follow-ups.\n- **Captions:** Added offline transcription settings and top/bottom positioning on supported displays.\n- **Translation:** Restored all 60 languages.\n- **Mentra Maps:** Clearer directions on G1/G2, less display flickering, and improved Android offline routing.\n- **Livestreamer:** More video-quality controls and custom streaming options, plus improved startup and sharing.\n- **Merge:** Tap to expand an insight; swipe down or back to dismiss it.\n- **Mentra Notes:** Smoother back navigation.\n- **Recorder:** Fixed audio sharing."),
9
9
  ]
@@ -3,9 +3,9 @@ import Foundation
3
3
  /// Generated by release CI. Do not edit in a release checkout.
4
4
  enum GeneratedReleaseMetadata {
5
5
  static let familyBaseVersion = "3.2.1"
6
- static let releaseIdentity = "3.2.1-dev.314"
7
- static let releaseSetId = "mentra-3.2.1-dev.314"
8
- static let sourceCommit = "00ab17c37df5902fbdb1a5824a41288b6850acc5"
9
- static let otaManifestUrl = "https://artifactscdn.mentraglass.com/Mentra-Community/MentraOS/releases/mentra-builds-v3.2.1/mentra-live-ota-3.2.1-dev.314.json"
10
- static let otaManifestSha256 = "1062c560fcced026e5b43245e84cdd5febfd603f3ad19fbebef8fac455ba21d6"
6
+ static let releaseIdentity = "3.2.1"
7
+ static let releaseSetId = "mentra-3.2.1"
8
+ static let sourceCommit = "be56e1456c69f681985642f376ed3d397ec022cc"
9
+ static let otaManifestUrl = "https://artifactscdn.mentraglass.com/Mentra-Community/MentraOS/releases/mentra-builds-v3.2.1/mentra-live-ota-3.2.1-beta.578.json"
10
+ static let otaManifestSha256 = "21af62269e29fd2ee6b9737745a895694bc221672b191d80795057b5a832f400"
11
11
  }
@@ -299,8 +299,22 @@ public final class MentraBluetoothSDK {
299
299
  private let wifiSessionCapabilities = WifiSessionCapabilities()
300
300
  private var configuredOtaVersionUrl: String?
301
301
 
302
+ /// Whether the app declares the `bluetooth-central` background mode. When false on iOS, the
303
+ /// glasses stay disconnected after a link drop until the user opens the app, so a software
304
+ /// update started on a locked phone stalls. Always true on macOS.
305
+ public static var supportsBackgroundReconnect: Bool {
306
+ BluetoothBackgroundMode.isDeclared(in: Bundle.main.infoDictionary)
307
+ }
308
+
309
+ private static var didWarnMissingBackgroundMode = false
310
+
302
311
  public init(configuration: MentraBluetoothSDKConfiguration = .default) {
303
312
  self.configuration = configuration
313
+ // Once per process: apps may create the SDK more than once.
314
+ if !Self.didWarnMissingBackgroundMode, !Self.supportsBackgroundReconnect {
315
+ Self.didWarnMissingBackgroundMode = true
316
+ Bridge.log(BluetoothBackgroundMode.missingWarning)
317
+ }
304
318
  analytics = BluetoothSdkAnalytics(configuration: configuration.analytics)
305
319
  bluetoothAvailabilityListenerId = BluetoothAvailability.shared.addStateListener { [weak self] state in
306
320
  Task { @MainActor [weak self] in
@@ -688,6 +702,10 @@ public final class MentraBluetoothSDK {
688
702
  DeviceStore.shared.apply(ObservableStore.bluetoothCategory, "loudness_gate_enabled", enabled)
689
703
  }
690
704
 
705
+ public func setAutoPowerOffEnabled(_ enabled: Bool) async throws {
706
+ DeviceStore.shared.apply(ObservableStore.bluetoothCategory, "auto_power_off_enabled", enabled)
707
+ }
708
+
691
709
  @available(*, deprecated, message: "Sticky action-button photo presets are deprecated. Prefer per-request requestPhoto(...) options (e.g. mode .text for text sensor size/crop, or explicit per-shot fields). This method still works but will be removed in a future release.")
692
710
  public func setPhotoCaptureDefaults(_ settings: PhotoCaptureDefaults) async throws -> SettingsAckEvent {
693
711
  try await performSettingsCommand(
@@ -0,0 +1,33 @@
1
+ import Foundation
2
+
3
+ /// Tracks expected audio separately from observed audio. All times use a monotonic clock.
4
+ struct GlassesMicWatchdog {
5
+ private var expectedSince: TimeInterval?
6
+ private var lastActivity: TimeInterval?
7
+ private var lastRetry: TimeInterval?
8
+ private let timeout: TimeInterval = 5
9
+
10
+ mutating func expectAudio(_ expected: Bool, at now: TimeInterval) {
11
+ guard expected else {
12
+ self = GlassesMicWatchdog()
13
+ return
14
+ }
15
+ if expectedSince == nil {
16
+ expectedSince = now
17
+ }
18
+ }
19
+
20
+ mutating func receivedAudio(at now: TimeInterval) {
21
+ guard expectedSince != nil else { return }
22
+ lastActivity = now
23
+ }
24
+
25
+ mutating func shouldRetry(at now: TimeInterval) -> Bool {
26
+ guard let expectedSince else { return false }
27
+ let lastProgress = max(expectedSince, lastActivity ?? expectedSince, lastRetry ?? expectedSince)
28
+ guard now - lastProgress >= timeout else { return false }
29
+ // Rate-limit retries even when the device never produces its first packet.
30
+ lastRetry = now
31
+ return true
32
+ }
33
+ }
@@ -1,7 +1,7 @@
1
1
  import Foundation
2
2
 
3
3
  enum DashboardContentFormatter {
4
- static let statusHeader = "$TIME12$ $DATE$ $GBATT$ $CONNECTION_STATUS$"
4
+ static let statusHeader = "$TIME12$ $DATE$ $GBATT$"
5
5
 
6
6
  static func template(for content: String) -> String {
7
7
  content.isEmpty ? statusHeader : statusHeader + "\n\n" + content
@@ -7,7 +7,7 @@ enum VersionInfoAccumulatorOutcome {
7
7
  case complete(VersionInfoResult)
8
8
  }
9
9
 
10
- /// One request, explicit modern completion, or the deployed legacy chunk-3 terminal boundary.
10
+ /// One request, explicit modern completion, or the legacy firmware's terminal chunk.
11
11
  final class VersionInfoResponseAccumulator {
12
12
  static let responseChunkKey = "_responseChunk"
13
13
  static let responseRequestIdKey = "_responseRequestId"
@@ -23,6 +23,7 @@ final class VersionInfoResponseAccumulator {
23
23
  private var count: Int?
24
24
  private var sid: String?
25
25
  private var legacyStarted = false
26
+ private var legacyTwoChunkResponse = false
26
27
  private var completed = false
27
28
 
28
29
  init(expectedRequestId: String) {
@@ -63,10 +64,19 @@ final class VersionInfoResponseAccumulator {
63
64
  case "version_info_1":
64
65
  values.removeAll()
65
66
  legacyStarted = true
67
+ legacyTwoChunkResponse = event["buildNumber"] as? String == "27"
66
68
  merge(event)
67
69
  case "version_info_2":
68
70
  guard legacyStarted else { return .ignored }
71
+ // Factory ASG27 sends only chunks 1 and 2. Its second chunk carries the
72
+ // OTA URL and legacy firmware version; ASG31+ moves firmware to chunk 3.
69
73
  merge(event)
74
+ if legacyTwoChunkResponse, values["buildNumber"] as? String == "27",
75
+ let otaUrl = event["otaVersionUrl"] as? String,
76
+ !otaUrl.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
77
+ {
78
+ return finish()
79
+ }
70
80
  case "version_info_3":
71
81
  guard legacyStarted else { return .ignored }
72
82
  merge(event)
@@ -1850,8 +1850,7 @@ final class Ar99: NSObject, CBCentralManagerDelegate, CBPeripheralDelegate, SGCM
1850
1850
  private func ensureOpusDecoder() {
1851
1851
  guard opusDecoder == nil else { return }
1852
1852
  opusDecoder = Ar99OpusPcmDecoder { pcm in
1853
- DeviceManager.shared.reportGlassesAudioActivity()
1854
- DeviceManager.shared.handlePcm(pcm)
1853
+ DeviceManager.shared.handleGlassesPcm(pcm)
1855
1854
  }
1856
1855
  if opusDecoder == nil {
1857
1856
  Bridge.log("AR99: system Opus decoder unavailable")