@mentra/engine 0.1.0-dev.0 → 3.1.0-beta.100

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 (574) hide show
  1. package/README.md +73 -18
  2. package/build/bluetooth-sdk/debug.d.ts +2 -0
  3. package/build/bluetooth-sdk/debug.d.ts.map +1 -0
  4. package/build/bluetooth-sdk/debug.js +2 -0
  5. package/build/bluetooth-sdk/debug.js.map +1 -0
  6. package/build/bluetooth-sdk/index.d.ts +3 -0
  7. package/build/bluetooth-sdk/index.d.ts.map +1 -0
  8. package/build/bluetooth-sdk/index.js +3 -0
  9. package/build/bluetooth-sdk/index.js.map +1 -0
  10. package/build/bluetooth-sdk/ota-transport.d.ts +2 -0
  11. package/build/bluetooth-sdk/ota-transport.d.ts.map +1 -0
  12. package/build/bluetooth-sdk/ota-transport.js +2 -0
  13. package/build/bluetooth-sdk/ota-transport.js.map +1 -0
  14. package/build/bluetooth-sdk/photo-receiver.d.ts +3 -0
  15. package/build/bluetooth-sdk/photo-receiver.d.ts.map +1 -0
  16. package/build/bluetooth-sdk/photo-receiver.js +3 -0
  17. package/build/bluetooth-sdk/photo-receiver.js.map +1 -0
  18. package/build/bluetooth-sdk/react.d.ts +2 -0
  19. package/build/bluetooth-sdk/react.d.ts.map +1 -0
  20. package/build/bluetooth-sdk/react.js +2 -0
  21. package/build/bluetooth-sdk/react.js.map +1 -0
  22. package/build/bluetooth-sdk/types.d.ts +2 -0
  23. package/build/bluetooth-sdk/types.d.ts.map +1 -0
  24. package/build/bluetooth-sdk/types.js +2 -0
  25. package/build/bluetooth-sdk/types.js.map +1 -0
  26. package/build/engine.d.ts +29 -18
  27. package/build/engine.d.ts.map +1 -1
  28. package/build/engine.js +9 -4
  29. package/build/engine.js.map +1 -1
  30. package/build/facades/dev.d.ts +3 -2
  31. package/build/facades/dev.d.ts.map +1 -1
  32. package/build/facades/dev.js +18 -12
  33. package/build/facades/dev.js.map +1 -1
  34. package/build/facades/display.d.ts +1 -1
  35. package/build/facades/display.d.ts.map +1 -1
  36. package/build/facades/display.js +1 -1
  37. package/build/facades/display.js.map +1 -1
  38. package/build/facades/gallery.js +1 -1
  39. package/build/facades/gallery.js.map +1 -1
  40. package/build/facades/glasses.d.ts +4 -13
  41. package/build/facades/glasses.d.ts.map +1 -1
  42. package/build/facades/glasses.js +21 -6
  43. package/build/facades/glasses.js.map +1 -1
  44. package/build/facades/glassesSettings.d.ts.map +1 -1
  45. package/build/facades/glassesSettings.js +1 -0
  46. package/build/facades/glassesSettings.js.map +1 -1
  47. package/build/facades/miniapps.d.ts +4 -0
  48. package/build/facades/miniapps.d.ts.map +1 -1
  49. package/build/facades/miniapps.js +5 -0
  50. package/build/facades/miniapps.js.map +1 -1
  51. package/build/facades/ota.d.ts +24 -21
  52. package/build/facades/ota.d.ts.map +1 -1
  53. package/build/facades/ota.js +31 -12
  54. package/build/facades/ota.js.map +1 -1
  55. package/build/facades/pairing.d.ts +12 -5
  56. package/build/facades/pairing.d.ts.map +1 -1
  57. package/build/facades/pairing.js +93 -17
  58. package/build/facades/pairing.js.map +1 -1
  59. package/build/facades/permissions.d.ts +10 -0
  60. package/build/facades/permissions.d.ts.map +1 -1
  61. package/build/facades/permissions.js +10 -0
  62. package/build/facades/permissions.js.map +1 -1
  63. package/build/facades/phoneNotifications.d.ts +0 -4
  64. package/build/facades/phoneNotifications.d.ts.map +1 -1
  65. package/build/facades/phoneNotifications.js +4 -14
  66. package/build/facades/phoneNotifications.js.map +1 -1
  67. package/build/facades/speech.d.ts +7 -6
  68. package/build/facades/speech.d.ts.map +1 -1
  69. package/build/facades/speech.js +1 -1
  70. package/build/facades/speech.js.map +1 -1
  71. package/build/generated/releaseMetadata.d.ts +12 -0
  72. package/build/generated/releaseMetadata.d.ts.map +1 -0
  73. package/build/generated/releaseMetadata.js +10 -0
  74. package/build/generated/releaseMetadata.js.map +1 -0
  75. package/build/index.d.ts +12 -10
  76. package/build/index.d.ts.map +1 -1
  77. package/build/index.js +7 -6
  78. package/build/index.js.map +1 -1
  79. package/build/react/MentraLiveOtaFlow.d.ts +44 -0
  80. package/build/react/MentraLiveOtaFlow.d.ts.map +1 -0
  81. package/build/react/MentraLiveOtaFlow.js +430 -0
  82. package/build/react/MentraLiveOtaFlow.js.map +1 -0
  83. package/build/react/index.d.ts +3 -0
  84. package/build/react/index.d.ts.map +1 -0
  85. package/build/react/index.js +3 -0
  86. package/build/react/index.js.map +1 -0
  87. package/build/react/useEngineSnapshot.d.ts +2 -0
  88. package/build/react/useEngineSnapshot.d.ts.map +1 -0
  89. package/build/react/useEngineSnapshot.js +14 -0
  90. package/build/react/useEngineSnapshot.js.map +1 -0
  91. package/build/react/useMentraLiveOta.d.ts +84 -0
  92. package/build/react/useMentraLiveOta.d.ts.map +1 -0
  93. package/build/react/useMentraLiveOta.js +627 -0
  94. package/build/react/useMentraLiveOta.js.map +1 -0
  95. package/build/runtime/bootstrap.d.ts +24 -1
  96. package/build/runtime/bootstrap.d.ts.map +1 -1
  97. package/build/runtime/bootstrap.js +17 -0
  98. package/build/runtime/bootstrap.js.map +1 -1
  99. package/build/runtime/scanQrSeam.d.ts +7 -0
  100. package/build/runtime/scanQrSeam.d.ts.map +1 -0
  101. package/build/runtime/scanQrSeam.js +16 -0
  102. package/build/runtime/scanQrSeam.js.map +1 -0
  103. package/build/services/AppRegistry.d.ts +41 -57
  104. package/build/services/AppRegistry.d.ts.map +1 -1
  105. package/build/services/AppRegistry.js +208 -144
  106. package/build/services/AppRegistry.js.map +1 -1
  107. package/build/services/AudioCloudUplink.d.ts +12 -0
  108. package/build/services/AudioCloudUplink.d.ts.map +1 -1
  109. package/build/services/AudioCloudUplink.js +22 -1
  110. package/build/services/AudioCloudUplink.js.map +1 -1
  111. package/build/services/AudioPlaybackService.d.ts +41 -2
  112. package/build/services/AudioPlaybackService.d.ts.map +1 -1
  113. package/build/services/AudioPlaybackService.js +250 -33
  114. package/build/services/AudioPlaybackService.js.map +1 -1
  115. package/build/services/BlobStore.d.ts +4 -1
  116. package/build/services/BlobStore.d.ts.map +1 -1
  117. package/build/services/BlobStore.js +80 -19
  118. package/build/services/BlobStore.js.map +1 -1
  119. package/build/services/CloudAudioSubscriptionSync.d.ts.map +1 -1
  120. package/build/services/CloudAudioSubscriptionSync.js +13 -1
  121. package/build/services/CloudAudioSubscriptionSync.js.map +1 -1
  122. package/build/services/CloudClientService.d.ts +15 -0
  123. package/build/services/CloudClientService.d.ts.map +1 -1
  124. package/build/services/CloudClientService.js +93 -1
  125. package/build/services/CloudClientService.js.map +1 -1
  126. package/build/services/DeviceEventRouter.js +5 -5
  127. package/build/services/DeviceEventRouter.js.map +1 -1
  128. package/build/services/DisplayProcessor.d.ts +3 -3
  129. package/build/services/DisplayProcessor.js +3 -3
  130. package/build/services/DisplayProcessor.js.map +1 -1
  131. package/build/services/ForegroundLocationPermission.d.ts +13 -0
  132. package/build/services/ForegroundLocationPermission.d.ts.map +1 -0
  133. package/build/services/ForegroundLocationPermission.js +12 -0
  134. package/build/services/ForegroundLocationPermission.js.map +1 -0
  135. package/build/services/GlassesSettingsSync.d.ts.map +1 -1
  136. package/build/services/GlassesSettingsSync.js +9 -3
  137. package/build/services/GlassesSettingsSync.js.map +1 -1
  138. package/build/services/GlassesStatusProjection.d.ts +15 -1
  139. package/build/services/GlassesStatusProjection.d.ts.map +1 -1
  140. package/build/services/GlassesStatusProjection.js +18 -11
  141. package/build/services/GlassesStatusProjection.js.map +1 -1
  142. package/build/services/HotspotOtaTransport.d.ts +29 -0
  143. package/build/services/HotspotOtaTransport.d.ts.map +1 -0
  144. package/build/services/HotspotOtaTransport.js +147 -0
  145. package/build/services/HotspotOtaTransport.js.map +1 -0
  146. package/build/services/HotspotShutdown.d.ts +17 -0
  147. package/build/services/HotspotShutdown.d.ts.map +1 -0
  148. package/build/services/HotspotShutdown.js +29 -0
  149. package/build/services/HotspotShutdown.js.map +1 -0
  150. package/build/services/LocalDisplayManager.d.ts +17 -3
  151. package/build/services/LocalDisplayManager.d.ts.map +1 -1
  152. package/build/services/LocalDisplayManager.js +108 -23
  153. package/build/services/LocalDisplayManager.js.map +1 -1
  154. package/build/services/LocalMiniappRuntime.d.ts +74 -2
  155. package/build/services/LocalMiniappRuntime.d.ts.map +1 -1
  156. package/build/services/LocalMiniappRuntime.js +785 -291
  157. package/build/services/LocalMiniappRuntime.js.map +1 -1
  158. package/build/services/LocalMiniappStorage.d.ts +32 -0
  159. package/build/services/LocalMiniappStorage.d.ts.map +1 -0
  160. package/build/services/LocalMiniappStorage.js +66 -0
  161. package/build/services/LocalMiniappStorage.js.map +1 -0
  162. package/build/services/LocalMiniappUserIdentity.d.ts +20 -0
  163. package/build/services/LocalMiniappUserIdentity.d.ts.map +1 -0
  164. package/build/services/LocalMiniappUserIdentity.js +43 -0
  165. package/build/services/LocalMiniappUserIdentity.js.map +1 -0
  166. package/build/services/LocalSttFallbackCoordinator.d.ts +9 -6
  167. package/build/services/LocalSttFallbackCoordinator.d.ts.map +1 -1
  168. package/build/services/LocalSttFallbackCoordinator.js +18 -12
  169. package/build/services/LocalSttFallbackCoordinator.js.map +1 -1
  170. package/build/services/MicStateCoordinator.d.ts +42 -1
  171. package/build/services/MicStateCoordinator.d.ts.map +1 -1
  172. package/build/services/MicStateCoordinator.js +141 -3
  173. package/build/services/MicStateCoordinator.js.map +1 -1
  174. package/build/services/MiniappLauncher.d.ts.map +1 -1
  175. package/build/services/MiniappLauncher.js +23 -3
  176. package/build/services/MiniappLauncher.js.map +1 -1
  177. package/build/services/MiniappLiveness.d.ts +12 -0
  178. package/build/services/MiniappLiveness.d.ts.map +1 -0
  179. package/build/services/MiniappLiveness.js +16 -0
  180. package/build/services/MiniappLiveness.js.map +1 -0
  181. package/build/services/NativeHttpResponse.d.ts +3 -0
  182. package/build/services/NativeHttpResponse.d.ts.map +1 -0
  183. package/build/services/NativeHttpResponse.js +6 -0
  184. package/build/services/NativeHttpResponse.js.map +1 -0
  185. package/build/services/OtaArtifactDownloader.d.ts +65 -0
  186. package/build/services/OtaArtifactDownloader.d.ts.map +1 -0
  187. package/build/services/OtaArtifactDownloader.js +226 -0
  188. package/build/services/OtaArtifactDownloader.js.map +1 -0
  189. package/build/services/OtaAutoChain.d.ts +41 -0
  190. package/build/services/OtaAutoChain.d.ts.map +1 -0
  191. package/build/services/OtaAutoChain.js +91 -0
  192. package/build/services/OtaAutoChain.js.map +1 -0
  193. package/build/services/OtaErrorMapping.d.ts +18 -0
  194. package/build/services/OtaErrorMapping.d.ts.map +1 -0
  195. package/build/services/OtaErrorMapping.js +63 -0
  196. package/build/services/OtaErrorMapping.js.map +1 -0
  197. package/build/services/OtaInstallCoordinator.d.ts +120 -17
  198. package/build/services/OtaInstallCoordinator.d.ts.map +1 -1
  199. package/build/services/OtaInstallCoordinator.js +605 -136
  200. package/build/services/OtaInstallCoordinator.js.map +1 -1
  201. package/build/services/OtaService.d.ts.map +1 -1
  202. package/build/services/OtaService.js +9 -2
  203. package/build/services/OtaService.js.map +1 -1
  204. package/build/services/OtaUpdateCheckService.d.ts +42 -4
  205. package/build/services/OtaUpdateCheckService.d.ts.map +1 -1
  206. package/build/services/OtaUpdateCheckService.js +158 -26
  207. package/build/services/OtaUpdateCheckService.js.map +1 -1
  208. package/build/services/PhoneCalendarHelpers.d.ts +32 -0
  209. package/build/services/PhoneCalendarHelpers.d.ts.map +1 -0
  210. package/build/services/PhoneCalendarHelpers.js +157 -0
  211. package/build/services/PhoneCalendarHelpers.js.map +1 -0
  212. package/build/services/PhoneCalendarService.d.ts +5 -0
  213. package/build/services/PhoneCalendarService.d.ts.map +1 -0
  214. package/build/services/PhoneCalendarService.js +45 -0
  215. package/build/services/PhoneCalendarService.js.map +1 -0
  216. package/build/services/PhoneCameraFovCoordinator.d.ts +30 -0
  217. package/build/services/PhoneCameraFovCoordinator.d.ts.map +1 -0
  218. package/build/services/PhoneCameraFovCoordinator.js +194 -0
  219. package/build/services/PhoneCameraFovCoordinator.js.map +1 -0
  220. package/build/services/PhoneNotificationsSync.d.ts.map +1 -1
  221. package/build/services/PhoneNotificationsSync.js +12 -10
  222. package/build/services/PhoneNotificationsSync.js.map +1 -1
  223. package/build/services/PhonePhotoCoordinator.d.ts +26 -2
  224. package/build/services/PhonePhotoCoordinator.d.ts.map +1 -1
  225. package/build/services/PhonePhotoCoordinator.js +80 -10
  226. package/build/services/PhonePhotoCoordinator.js.map +1 -1
  227. package/build/services/PhoneStreamCoordinator.d.ts +8 -1
  228. package/build/services/PhoneStreamCoordinator.d.ts.map +1 -1
  229. package/build/services/PhoneStreamCoordinator.js +125 -13
  230. package/build/services/PhoneStreamCoordinator.js.map +1 -1
  231. package/build/services/PhoneVideoCoordinator.d.ts +2 -0
  232. package/build/services/PhoneVideoCoordinator.d.ts.map +1 -1
  233. package/build/services/PhoneVideoCoordinator.js +10 -0
  234. package/build/services/PhoneVideoCoordinator.js.map +1 -1
  235. package/build/services/SceneRenderer.d.ts.map +1 -1
  236. package/build/services/SceneRenderer.js +15 -0
  237. package/build/services/SceneRenderer.js.map +1 -1
  238. package/build/services/SentenceTtsPipeline.d.ts +31 -0
  239. package/build/services/SentenceTtsPipeline.d.ts.map +1 -0
  240. package/build/services/SentenceTtsPipeline.js +67 -0
  241. package/build/services/SentenceTtsPipeline.js.map +1 -0
  242. package/build/services/StreamLifecycleController.d.ts +3 -3
  243. package/build/services/StreamLifecycleController.js +3 -3
  244. package/build/services/StreamLifecycleController.js.map +1 -1
  245. package/build/services/SupportProfileSync.d.ts +22 -0
  246. package/build/services/SupportProfileSync.d.ts.map +1 -0
  247. package/build/services/SupportProfileSync.js +213 -0
  248. package/build/services/SupportProfileSync.js.map +1 -0
  249. package/build/services/TranscriptionRouting.d.ts +12 -0
  250. package/build/services/TranscriptionRouting.d.ts.map +1 -0
  251. package/build/services/TranscriptionRouting.js +24 -0
  252. package/build/services/TranscriptionRouting.js.map +1 -0
  253. package/build/services/TtsTextSanitizer.d.ts +5 -0
  254. package/build/services/TtsTextSanitizer.d.ts.map +1 -0
  255. package/build/services/TtsTextSanitizer.js +61 -0
  256. package/build/services/TtsTextSanitizer.js.map +1 -0
  257. package/build/services/WifiAdbRequest.d.ts +17 -0
  258. package/build/services/WifiAdbRequest.d.ts.map +1 -0
  259. package/build/services/WifiAdbRequest.js +35 -0
  260. package/build/services/WifiAdbRequest.js.map +1 -0
  261. package/build/services/asg/asgCameraApi.d.ts +43 -0
  262. package/build/services/asg/asgCameraApi.d.ts.map +1 -1
  263. package/build/services/asg/asgCameraApi.js +509 -26
  264. package/build/services/asg/asgCameraApi.js.map +1 -1
  265. package/build/services/asg/cameraRollExportCoordinator.d.ts +60 -0
  266. package/build/services/asg/cameraRollExportCoordinator.d.ts.map +1 -0
  267. package/build/services/asg/cameraRollExportCoordinator.js +618 -0
  268. package/build/services/asg/cameraRollExportCoordinator.js.map +1 -0
  269. package/build/services/asg/cameraRollExportLedger.d.ts +44 -0
  270. package/build/services/asg/cameraRollExportLedger.d.ts.map +1 -0
  271. package/build/services/asg/cameraRollExportLedger.js +78 -0
  272. package/build/services/asg/cameraRollExportLedger.js.map +1 -0
  273. package/build/services/asg/galleryNotices.d.ts +1 -1
  274. package/build/services/asg/galleryNotices.d.ts.map +1 -1
  275. package/build/services/asg/galleryNotices.js.map +1 -1
  276. package/build/services/asg/galleryPathMigration.d.ts +3 -0
  277. package/build/services/asg/galleryPathMigration.d.ts.map +1 -0
  278. package/build/services/asg/galleryPathMigration.js +26 -0
  279. package/build/services/asg/galleryPathMigration.js.map +1 -0
  280. package/build/services/asg/gallerySettingsService.d.ts +1 -0
  281. package/build/services/asg/gallerySettingsService.d.ts.map +1 -1
  282. package/build/services/asg/gallerySettingsService.js +2 -0
  283. package/build/services/asg/gallerySettingsService.js.map +1 -1
  284. package/build/services/asg/gallerySyncService.d.ts.map +1 -1
  285. package/build/services/asg/gallerySyncService.js +162 -56
  286. package/build/services/asg/gallerySyncService.js.map +1 -1
  287. package/build/services/asg/galleryTransferLedger.d.ts +58 -0
  288. package/build/services/asg/galleryTransferLedger.d.ts.map +1 -0
  289. package/build/services/asg/galleryTransferLedger.js +225 -0
  290. package/build/services/asg/galleryTransferLedger.js.map +1 -0
  291. package/build/services/asg/localNetworkTransport.d.ts +15 -0
  292. package/build/services/asg/localNetworkTransport.d.ts.map +1 -0
  293. package/build/services/asg/localNetworkTransport.js +145 -0
  294. package/build/services/asg/localNetworkTransport.js.map +1 -0
  295. package/build/services/asg/localStorageService.d.ts +18 -0
  296. package/build/services/asg/localStorageService.d.ts.map +1 -1
  297. package/build/services/asg/localStorageService.js +168 -19
  298. package/build/services/asg/localStorageService.js.map +1 -1
  299. package/build/services/asg/mediaProcessingQueue.d.ts +10 -0
  300. package/build/services/asg/mediaProcessingQueue.d.ts.map +1 -1
  301. package/build/services/asg/mediaProcessingQueue.js +193 -62
  302. package/build/services/asg/mediaProcessingQueue.js.map +1 -1
  303. package/build/services/audioPlaybackAssets.d.ts +8 -0
  304. package/build/services/audioPlaybackAssets.d.ts.map +1 -0
  305. package/build/services/audioPlaybackAssets.js +7 -0
  306. package/build/services/audioPlaybackAssets.js.map +1 -0
  307. package/build/services/gallerySyncClock.js +1 -1
  308. package/build/services/gallerySyncClock.js.map +1 -1
  309. package/build/services/glassesClockSync.d.ts.map +1 -1
  310. package/build/services/glassesClockSync.js +7 -3
  311. package/build/services/glassesClockSync.js.map +1 -1
  312. package/build/services/manifestActions.d.ts +7 -0
  313. package/build/services/manifestActions.d.ts.map +1 -0
  314. package/build/services/manifestActions.js +25 -0
  315. package/build/services/manifestActions.js.map +1 -0
  316. package/build/services/otaDisplayState.d.ts +23 -12
  317. package/build/services/otaDisplayState.d.ts.map +1 -1
  318. package/build/services/otaDisplayState.js +36 -14
  319. package/build/services/otaDisplayState.js.map +1 -1
  320. package/build/services/otaInstallPolicy.d.ts +20 -4
  321. package/build/services/otaInstallPolicy.d.ts.map +1 -1
  322. package/build/services/otaInstallPolicy.js +28 -3
  323. package/build/services/otaInstallPolicy.js.map +1 -1
  324. package/build/services/otaLegacyMapping.d.ts +1 -1
  325. package/build/services/otaLegacyMapping.d.ts.map +1 -1
  326. package/build/services/otaLegacyMapping.js.map +1 -1
  327. package/build/services/otaManifestPolicy.d.ts +14 -0
  328. package/build/services/otaManifestPolicy.d.ts.map +1 -0
  329. package/build/services/otaManifestPolicy.js +19 -0
  330. package/build/services/otaManifestPolicy.js.map +1 -0
  331. package/build/services/otaManifestUrl.d.ts +3 -0
  332. package/build/services/otaManifestUrl.d.ts.map +1 -0
  333. package/build/services/otaManifestUrl.js +38 -0
  334. package/build/services/otaManifestUrl.js.map +1 -0
  335. package/build/services/otaReleaseVersion.d.ts +10 -0
  336. package/build/services/otaReleaseVersion.d.ts.map +1 -0
  337. package/build/services/otaReleaseVersion.js +14 -0
  338. package/build/services/otaReleaseVersion.js.map +1 -0
  339. package/build/services/slimStreamStatus.d.ts +11 -0
  340. package/build/services/slimStreamStatus.d.ts.map +1 -1
  341. package/build/services/slimStreamStatus.js +14 -4
  342. package/build/services/slimStreamStatus.js.map +1 -1
  343. package/build/stores/appVisibility.d.ts +3 -0
  344. package/build/stores/appVisibility.d.ts.map +1 -0
  345. package/build/stores/appVisibility.js +3 -0
  346. package/build/stores/appVisibility.js.map +1 -0
  347. package/build/stores/apps.d.ts.map +1 -1
  348. package/build/stores/apps.js +18 -1
  349. package/build/stores/apps.js.map +1 -1
  350. package/build/stores/bluetoothSettingKeys.d.ts.map +1 -1
  351. package/build/stores/bluetoothSettingKeys.js +2 -0
  352. package/build/stores/bluetoothSettingKeys.js.map +1 -1
  353. package/build/stores/core.d.ts +3 -3
  354. package/build/stores/core.d.ts.map +1 -1
  355. package/build/stores/core.js.map +1 -1
  356. package/build/stores/gallerySync.d.ts +2 -0
  357. package/build/stores/gallerySync.d.ts.map +1 -1
  358. package/build/stores/gallerySync.js +2 -0
  359. package/build/stores/gallerySync.js.map +1 -1
  360. package/build/stores/glasses.d.ts +10 -4
  361. package/build/stores/glasses.d.ts.map +1 -1
  362. package/build/stores/glasses.js +3 -0
  363. package/build/stores/glasses.js.map +1 -1
  364. package/build/stores/settings.d.ts +17 -0
  365. package/build/stores/settings.d.ts.map +1 -1
  366. package/build/stores/settings.js +157 -17
  367. package/build/stores/settings.js.map +1 -1
  368. package/build/types/applet.d.ts +3 -1
  369. package/build/types/applet.d.ts.map +1 -1
  370. package/build/types/applet.js +1 -1
  371. package/build/types/applet.js.map +1 -1
  372. package/build/types/asg.d.ts +7 -0
  373. package/build/types/asg.d.ts.map +1 -1
  374. package/build/types/asg.js.map +1 -1
  375. package/build/types/capabilities/even-realities-g1.d.ts.map +1 -1
  376. package/build/types/capabilities/even-realities-g1.js +2 -0
  377. package/build/types/capabilities/even-realities-g1.js.map +1 -1
  378. package/build/types/capabilities/even-realities-g2.d.ts.map +1 -1
  379. package/build/types/capabilities/even-realities-g2.js +2 -0
  380. package/build/types/capabilities/even-realities-g2.js.map +1 -1
  381. package/build/types/capabilities/mentra-display.d.ts.map +1 -1
  382. package/build/types/capabilities/mentra-display.js +2 -0
  383. package/build/types/capabilities/mentra-display.js.map +1 -1
  384. package/build/types/capabilities/mentra-live.d.ts.map +1 -1
  385. package/build/types/capabilities/mentra-live.js +2 -0
  386. package/build/types/capabilities/mentra-live.js.map +1 -1
  387. package/build/types/capabilities/nimo.d.ts +9 -0
  388. package/build/types/capabilities/nimo.d.ts.map +1 -0
  389. package/build/types/capabilities/nimo.js +57 -0
  390. package/build/types/capabilities/nimo.js.map +1 -0
  391. package/build/types/capabilities/none.d.ts.map +1 -1
  392. package/build/types/capabilities/none.js +2 -0
  393. package/build/types/capabilities/none.js.map +1 -1
  394. package/build/types/capabilities/simulated-glasses.d.ts.map +1 -1
  395. package/build/types/capabilities/simulated-glasses.js +2 -0
  396. package/build/types/capabilities/simulated-glasses.js.map +1 -1
  397. package/build/types/capabilities/vuzix-z100.d.ts.map +1 -1
  398. package/build/types/capabilities/vuzix-z100.js +2 -0
  399. package/build/types/capabilities/vuzix-z100.js.map +1 -1
  400. package/build/types/enums.d.ts +4 -26
  401. package/build/types/enums.d.ts.map +1 -1
  402. package/build/types/enums.js +3 -27
  403. package/build/types/enums.js.map +1 -1
  404. package/build/types/hardware.d.ts +6 -14
  405. package/build/types/hardware.d.ts.map +1 -1
  406. package/build/types/hardware.js +18 -4
  407. package/build/types/hardware.js.map +1 -1
  408. package/build/types/index.d.ts +3 -7
  409. package/build/types/index.d.ts.map +1 -1
  410. package/build/types/index.js +3 -7
  411. package/build/types/index.js.map +1 -1
  412. package/build/utils/configuredDevHost.d.ts +6 -0
  413. package/build/utils/configuredDevHost.d.ts.map +1 -0
  414. package/build/utils/configuredDevHost.js +19 -0
  415. package/build/utils/configuredDevHost.js.map +1 -0
  416. package/build/utils/devMiniappLaunch.d.ts +19 -8
  417. package/build/utils/devMiniappLaunch.d.ts.map +1 -1
  418. package/build/utils/devMiniappLaunch.js +144 -21
  419. package/build/utils/devMiniappLaunch.js.map +1 -1
  420. package/build/utils/diagnosticContext.d.ts.map +1 -1
  421. package/build/utils/diagnosticContext.js +15 -17
  422. package/build/utils/diagnosticContext.js.map +1 -1
  423. package/build/utils/display/composer/ColumnComposer.d.ts +1 -1
  424. package/build/utils/display/composer/ColumnComposer.js +1 -1
  425. package/build/utils/display/composer/ColumnComposer.js.map +1 -1
  426. package/build/utils/hardware/hardware.d.ts.map +1 -1
  427. package/build/utils/hardware/hardware.js +7 -3
  428. package/build/utils/hardware/hardware.js.map +1 -1
  429. package/build/utils/miniappDiagnostics.d.ts +64 -0
  430. package/build/utils/miniappDiagnostics.d.ts.map +1 -0
  431. package/build/utils/miniappDiagnostics.js +173 -0
  432. package/build/utils/miniappDiagnostics.js.map +1 -0
  433. package/build/utils/permissions/MediaLibraryPermissions.d.ts +11 -0
  434. package/build/utils/permissions/MediaLibraryPermissions.d.ts.map +1 -1
  435. package/build/utils/permissions/MediaLibraryPermissions.js +27 -4
  436. package/build/utils/permissions/MediaLibraryPermissions.js.map +1 -1
  437. package/build/utils/timers/timers.d.ts.map +1 -1
  438. package/build/utils/timers/timers.js +55 -18
  439. package/build/utils/timers/timers.js.map +1 -1
  440. package/package.json +66 -31
  441. package/src/assets/silence.wav +0 -0
  442. package/src/bluetooth-sdk/debug.ts +1 -0
  443. package/src/bluetooth-sdk/index.ts +2 -0
  444. package/src/bluetooth-sdk/ota-transport.ts +1 -0
  445. package/src/bluetooth-sdk/photo-receiver.ts +2 -0
  446. package/src/bluetooth-sdk/react.ts +1 -0
  447. package/src/bluetooth-sdk/types.ts +1 -0
  448. package/src/engine.ts +9 -4
  449. package/src/facades/dev.ts +17 -12
  450. package/src/facades/display.ts +1 -1
  451. package/src/facades/gallery.ts +1 -1
  452. package/src/facades/glasses.ts +20 -7
  453. package/src/facades/glassesSettings.ts +5 -0
  454. package/src/facades/miniapps.ts +7 -0
  455. package/src/facades/ota.ts +41 -13
  456. package/src/facades/pairing.ts +103 -23
  457. package/src/facades/permissions.ts +14 -0
  458. package/src/facades/phoneNotifications.ts +4 -13
  459. package/src/facades/speech.ts +21 -7
  460. package/src/generated/releaseMetadata.ts +20 -0
  461. package/src/index.ts +42 -13
  462. package/src/react/MentraLiveOtaFlow.tsx +754 -0
  463. package/src/react/index.ts +21 -0
  464. package/src/react/useEngineSnapshot.ts +16 -0
  465. package/src/react/useMentraLiveOta.ts +793 -0
  466. package/src/runtime/bootstrap.ts +33 -1
  467. package/src/runtime/scanQrSeam.ts +20 -0
  468. package/src/services/AppRegistry.ts +240 -149
  469. package/src/services/AudioCloudUplink.ts +23 -1
  470. package/src/services/AudioPlaybackService.ts +287 -38
  471. package/src/services/BlobStore.ts +79 -19
  472. package/src/services/CloudAudioSubscriptionSync.ts +13 -1
  473. package/src/services/CloudClientService.ts +96 -1
  474. package/src/services/DeviceEventRouter.ts +5 -5
  475. package/src/services/DisplayProcessor.ts +3 -3
  476. package/src/services/ForegroundLocationPermission.ts +24 -0
  477. package/src/services/GlassesSettingsSync.ts +9 -3
  478. package/src/services/GlassesStatusProjection.ts +20 -11
  479. package/src/services/HotspotOtaTransport.ts +185 -0
  480. package/src/services/HotspotShutdown.ts +41 -0
  481. package/src/services/LocalDisplayManager.ts +114 -24
  482. package/src/services/LocalMiniappRuntime.ts +967 -346
  483. package/src/services/LocalMiniappStorage.ts +82 -0
  484. package/src/services/LocalMiniappUserIdentity.ts +46 -0
  485. package/src/services/LocalSttFallbackCoordinator.ts +19 -12
  486. package/src/services/MicStateCoordinator.ts +169 -4
  487. package/src/services/MiniappLauncher.ts +30 -3
  488. package/src/services/MiniappLiveness.ts +24 -0
  489. package/src/services/NativeHttpResponse.ts +6 -0
  490. package/src/services/OtaArtifactDownloader.ts +312 -0
  491. package/src/services/OtaAutoChain.ts +130 -0
  492. package/src/services/OtaErrorMapping.ts +80 -0
  493. package/src/services/OtaInstallCoordinator.ts +680 -151
  494. package/src/services/OtaService.ts +12 -7
  495. package/src/services/OtaUpdateCheckService.ts +205 -27
  496. package/src/services/PhoneCalendarHelpers.ts +212 -0
  497. package/src/services/PhoneCalendarService.ts +65 -0
  498. package/src/services/PhoneCameraFovCoordinator.ts +210 -0
  499. package/src/services/PhoneNotificationsSync.ts +12 -10
  500. package/src/services/PhonePhotoCoordinator.ts +117 -19
  501. package/src/services/PhoneStreamCoordinator.ts +139 -25
  502. package/src/services/PhoneVideoCoordinator.ts +11 -0
  503. package/src/services/SceneRenderer.ts +16 -0
  504. package/src/services/SentenceTtsPipeline.ts +106 -0
  505. package/src/services/StreamLifecycleController.ts +3 -3
  506. package/src/services/SupportProfileSync.ts +218 -0
  507. package/src/services/TranscriptionRouting.ts +37 -0
  508. package/src/services/TtsTextSanitizer.ts +66 -0
  509. package/src/services/WifiAdbRequest.ts +53 -0
  510. package/src/services/asg/asgCameraApi.ts +615 -33
  511. package/src/services/asg/cameraRollExportCoordinator.ts +627 -0
  512. package/src/services/asg/cameraRollExportLedger.ts +122 -0
  513. package/src/services/asg/galleryNotices.ts +1 -0
  514. package/src/services/asg/galleryPathMigration.ts +22 -0
  515. package/src/services/asg/gallerySettingsService.ts +3 -0
  516. package/src/services/asg/gallerySyncService.ts +184 -73
  517. package/src/services/asg/galleryTransferLedger.ts +312 -0
  518. package/src/services/asg/localNetworkTransport.ts +170 -0
  519. package/src/services/asg/localStorageService.ts +179 -24
  520. package/src/services/asg/mediaProcessingQueue.ts +209 -62
  521. package/src/services/audioPlaybackAssets.ts +8 -0
  522. package/src/services/gallerySyncClock.ts +1 -1
  523. package/src/services/glassesClockSync.ts +7 -3
  524. package/src/services/manifestActions.ts +30 -0
  525. package/src/services/otaDisplayState.ts +58 -17
  526. package/src/services/otaInstallPolicy.ts +34 -4
  527. package/src/services/otaLegacyMapping.ts +1 -1
  528. package/src/services/otaManifestPolicy.ts +42 -0
  529. package/src/services/otaManifestUrl.ts +44 -0
  530. package/src/services/otaReleaseVersion.ts +22 -0
  531. package/src/services/slimStreamStatus.ts +16 -5
  532. package/src/stores/appVisibility.ts +3 -0
  533. package/src/stores/apps.ts +19 -1
  534. package/src/stores/bluetoothSettingKeys.ts +2 -0
  535. package/src/stores/core.ts +4 -4
  536. package/src/stores/gallerySync.ts +4 -0
  537. package/src/stores/glasses.ts +16 -6
  538. package/src/stores/settings.ts +179 -18
  539. package/src/types/applet.ts +3 -1
  540. package/src/types/asg.ts +7 -0
  541. package/src/types/capabilities/even-realities-g1.ts +3 -0
  542. package/src/types/capabilities/even-realities-g2.ts +3 -0
  543. package/src/types/capabilities/mentra-display.ts +3 -0
  544. package/src/types/capabilities/mentra-live.ts +3 -0
  545. package/src/types/capabilities/nimo.ts +59 -0
  546. package/src/types/capabilities/none.ts +3 -0
  547. package/src/types/capabilities/simulated-glasses.ts +3 -0
  548. package/src/types/capabilities/vuzix-z100.ts +3 -0
  549. package/src/types/enums.ts +4 -28
  550. package/src/types/hardware.ts +23 -14
  551. package/src/types/index.ts +3 -6
  552. package/src/utils/configuredDevHost.ts +17 -0
  553. package/src/utils/devMiniappLaunch.ts +151 -23
  554. package/src/utils/diagnosticContext.ts +17 -17
  555. package/src/utils/display/composer/ColumnComposer.ts +1 -1
  556. package/src/utils/hardware/hardware.ts +7 -9
  557. package/src/utils/miniappDiagnostics.ts +226 -0
  558. package/src/utils/permissions/MediaLibraryPermissions.ts +35 -4
  559. package/src/utils/timers/timers.ts +57 -21
  560. package/build/devtools.d.ts +0 -11
  561. package/build/devtools.d.ts.map +0 -1
  562. package/build/devtools.js +0 -13
  563. package/build/devtools.js.map +0 -1
  564. package/build/internal.d.ts +0 -66
  565. package/build/internal.d.ts.map +0 -1
  566. package/build/internal.js +0 -89
  567. package/build/internal.js.map +0 -1
  568. package/build/services/asgOtaVersionUrl.d.ts +0 -2
  569. package/build/services/asgOtaVersionUrl.d.ts.map +0 -1
  570. package/build/services/asgOtaVersionUrl.js +0 -36
  571. package/build/services/asgOtaVersionUrl.js.map +0 -1
  572. package/src/devtools.ts +0 -13
  573. package/src/internal.ts +0 -158
  574. package/src/services/asgOtaVersionUrl.ts +0 -41
@@ -2,8 +2,8 @@
2
2
  * OtaInstallCoordinator — engine-owned OTA install state machine (WP 8B).
3
3
  *
4
4
  * Owns every timer/retry/sequencing rule that used to live in the host screen
5
- * `mobile/src/app/ota/progress.tsx`: the global session timeout, the no-ack
6
- * retry watchdog, the stuck-at-zero watchdog, the progress-stall watchdog keyed
5
+ * `mobile/src/app/ota/progress.tsx`: the global session timeout, serialized
6
+ * ota_start retries, the stuck-at-zero watchdog, the progress-stall watchdog keyed
7
7
  * on a staleness signature, connect-edge arbitration (initial mount vs
8
8
  * reconnect vs post-APK reboot), the ota_query_status reply fallback, the ping
9
9
  * keepalive, the ota_start_ack / mtk_update_complete listeners, and terminal
@@ -11,27 +11,27 @@
11
11
  * `snapshot()`/`onSnapshot()` plus the `attach()/detach()/retry()/finish()`
12
12
  * commands (exposed as `engine.ota.installSession`).
13
13
  *
14
- * Behavior contract: everything here was MOVED, not changed — every timer
15
- * duration (see ./otaInstallPolicy), arbitration rule, and "[OTA_PROGRESS]"
16
- * log line is a field-debugging contract and stays byte-identical to the old
17
- * screen. The reaction pass mirrors the old component's effect ordering: a
14
+ * The reaction pass mirrors the old component's effect ordering: a
18
15
  * pass diffs the store-derived inputs (connected / otaStatus / otaProgress /
19
16
  * displayState / stall signature) exactly like the old effect dep arrays, and
20
17
  * state written mid-pass queues a follow-up pass — the same way a setState
21
18
  * during React effects scheduled a re-render after the current effect batch.
22
19
  */
23
- import BluetoothSdk from "@mentra/bluetooth-sdk/internal"
24
- import type {OtaProgress, OtaStatus} from "@mentra/bluetooth-sdk/internal"
20
+ import BluetoothSdk, {type OtaProgress, type OtaStatus} from "@mentra/bluetooth-sdk"
25
21
  import GlobalEventEmitter from "../utils/GlobalEventEmitter"
26
22
  import {isGlassesConnected, useGlassesStore} from "../stores/glasses"
27
- import {getAsgOtaVersionUrl} from "./asgOtaVersionUrl"
23
+ import {resolveOtaManifestUrl} from "./otaManifestUrl"
24
+ import {hotspotOtaTransport, type HotspotOtaPhase} from "./HotspotOtaTransport"
25
+ import type {OtaCheckCurrentGlassesResult} from "./OtaUpdateCheckService"
28
26
  import {deriveDisplayState, type DisplayState} from "./otaDisplayState"
29
27
  import {
30
28
  BES_CONTINUE_LOCKOUT_MS,
29
+ BES_RESTART_TIMEOUT_MS,
31
30
  DOWNLOAD_STUCK_TIMEOUT_MS,
32
31
  GLOBAL_OTA_TIMEOUT_MS,
33
32
  LEGACY_APK_COMPLETION_SETTLE_MS,
34
33
  LEGACY_BES_CONTINUE_LOCKOUT_MS,
34
+ LEGACY_BES_RESTART_TIMEOUT_MS,
35
35
  LEGACY_DOWNLOAD_STUCK_TIMEOUT_MS,
36
36
  LEGACY_GLOBAL_OTA_TIMEOUT_MS,
37
37
  LEGACY_MTK_INSTALL_TIMEOUT_MS,
@@ -48,18 +48,41 @@ import {
48
48
  MTK_INSTALL_TIMEOUT_MS,
49
49
  OtaProgressMessages,
50
50
  PING_INTERVAL_MS,
51
- POST_APK_OTA_START_DELAY_MS,
52
51
  PROGRESS_TIMEOUT_MS,
53
52
  QUERY_REPLY_TIMEOUT_MS,
54
53
  RETRY_INTERVAL_MS,
55
54
  isLegacyShapedOtaSession,
56
55
  isLegacyShapedOtaStatus,
56
+ selectOtaProtocolProfile,
57
+ type OtaProtocolProfile,
57
58
  } from "./otaInstallPolicy"
58
59
 
59
60
  function isTerminalForWatchdog(d: DisplayState): boolean {
60
61
  return d === "complete" || d === "failed" || d === "restarting"
61
62
  }
62
63
 
64
+ function hotspotPreflightErrorMessage(error: unknown): string {
65
+ const code =
66
+ typeof error === "object" && error !== null && "code" in error ? String((error as {code: unknown}).code) : ""
67
+ switch (code) {
68
+ case "artifact_download_failed":
69
+ return OtaProgressMessages.hotspotArtifactDownloadFailed
70
+ case "artifact_verify_failed":
71
+ case "manifest_invalid":
72
+ return OtaProgressMessages.hotspotArtifactVerifyFailed
73
+ case "hotspot_wifi_permission_denied":
74
+ return OtaProgressMessages.hotspotWifiPermissionDenied
75
+ case "hotspot_start_failed":
76
+ return OtaProgressMessages.hotspotStartFailed
77
+ case "hotspot_join_failed":
78
+ return OtaProgressMessages.hotspotJoinFailed
79
+ case "hotspot_server_failed":
80
+ return OtaProgressMessages.hotspotServerFailed
81
+ default:
82
+ return OtaProgressMessages.hotspotServerFailed
83
+ }
84
+ }
85
+
63
86
  /** Non-empty when glasses are actively reporting work — drives stall timeout reset. */
64
87
  function buildProgressStalenessSignature(
65
88
  otaStatus: OtaStatus | null,
@@ -134,10 +157,38 @@ export interface OtaInstallSnapshot {
134
157
  * +1% ticks up to 60% purely to reassure the user. Render max(real, simulated).
135
158
  */
136
159
  mtkInstallStallSimulatedPercent: number | null
160
+ /** True for a downgrade (version-change) session — the UI narrates it differently. */
161
+ isVersionChange: boolean
162
+ /** True once the glasses reported the exact pinned target (the downgrade truly completed). */
163
+ versionChangeConverged: boolean
164
+ /**
165
+ * Sub-phase of the downgrade detour for the progress narrative, or null when not
166
+ * in one: "installing" before the handoff, "restarting" while the glasses are dark
167
+ * (uninstall / factory flicker / target install), "verifying" once reconnected and
168
+ * checking the version. Completion is the "complete" displayState.
169
+ */
170
+ versionChangePhase: "installing" | "restarting" | "verifying" | null
171
+ /** Pre-ota_start phone staging/join state for a hotspot attempt. */
172
+ hotspotPhase: HotspotOtaPhase
173
+ hotspotArtifactPercent: number | null
174
+ /** Transport selected from the checked glasses capabilities and Wi-Fi state. */
175
+ transport: "wifi" | "hotspot"
176
+ }
177
+
178
+ type OtaStartOutcome = "pending" | "acknowledged" | "rejected"
179
+
180
+ interface OtaStartOwnership {
181
+ outcome: OtaStartOutcome
182
+ promise: Promise<void>
137
183
  }
138
184
 
139
185
  class OtaInstallCoordinator {
140
186
  private attached = false
187
+ private preparedCheckResult: OtaCheckCurrentGlassesResult | null = null
188
+ private selectedTransport: "wifi" | "hotspot" = "wifi"
189
+ private hotspotManifestUrl: string | null = null
190
+ private hotspotPhase: HotspotOtaPhase = "idle"
191
+ private hotspotArtifactPercent: number | null = null
141
192
 
142
193
  // Genuinely session-local state (was component state/refs).
143
194
  private errorMsg = ""
@@ -157,6 +208,30 @@ class OtaInstallCoordinator {
157
208
  private apkExpectedInSession = false
158
209
  // Latched when the legacy build-number completion fires; projected as "complete".
159
210
  private apkCompletedViaBuildIncrease = false
211
+ // The split routes selected legacy/unified behavior before navigation. Keep
212
+ // that choice sticky while an APK step upgrades ASG mid-flow.
213
+ private protocolProfile: OtaProtocolProfile | null = null
214
+
215
+ // BES reboot recovery. Native bridges normalize explicit BES success to a
216
+ // terminal status; pre-37 flows additionally retain the legacy screen's
217
+ // lost-FINISHED fallback. Completion still requires a BES-specific observed
218
+ // disconnect -> reconnect lifecycle, never the generic session reconnect flag.
219
+ private besRestartExpected = false
220
+ private besRestartDisconnectObserved = false
221
+ private besRestartRecovery: "awaiting" | "complete" | null = null
222
+
223
+ // Downgrade detour (version-change mode). Captured at attach from the selected
224
+ // update: the pinned target is a LOWER build, executed by the recovery worker via
225
+ // uninstall->reinstall, so the phone must NOT drive ota_start on the intermediate
226
+ // reconnect (the passive factory build) and completion is exact-version
227
+ // convergence, not a build-number increase.
228
+ private versionChangeSession = false
229
+ private versionChangeTarget: number | null = null
230
+ // Set once ASG reports the apk install has started (the handoff point after which
231
+ // ASG dies and the recovery worker owns the transaction).
232
+ private versionChangeInstallStarted = false
233
+ // Latched when a reconnect reports buildNumber === versionChangeTarget.
234
+ private versionChangeConverged = false
160
235
  // Holds a legacy apk install FINISHED out of "complete" for the settle window.
161
236
  private legacyApkSettleHold = false
162
237
  // Display-only legacy MTK install stall simulation.
@@ -169,16 +244,22 @@ class OtaInstallCoordinator {
169
244
  private retryTimeout: ReturnType<typeof setTimeout> | null = null
170
245
  private stuckTimeout: ReturnType<typeof setTimeout> | null = null
171
246
  private progressTimeout: ReturnType<typeof setTimeout> | null = null
172
- private postApkDelay: ReturnType<typeof setTimeout> | null = null
173
247
  private pingInterval: ReturnType<typeof setInterval> | null = null
174
248
  private queryReplyTimeout: ReturnType<typeof setTimeout> | null = null
175
249
  private continueLockoutTimer: ReturnType<typeof setTimeout> | null = null
250
+ private restartRecoveryTimeout: ReturnType<typeof setTimeout> | null = null
176
251
  private legacyApkSettleTimer: ReturnType<typeof setTimeout> | null = null
177
252
  private mtkStallDetectTimer: ReturnType<typeof setTimeout> | null = null
178
253
  private mtkSimTickTimer: ReturnType<typeof setInterval> | null = null
179
254
 
180
- // Retry / ack bookkeeping (no glasses source)
181
- private hasReceivedAck = false
255
+ // Request / retry bookkeeping (no glasses source)
256
+ // At most one apk-install status poll in flight (native rejects concurrent queries).
257
+ private apkInstallPollInFlight = false
258
+ // The native SDK owns one ota_start request until its application-level ack or
259
+ // timeout. Keep pending ownership across attach/detach, and retain the settled
260
+ // outcome while attached so Retry/query fallback cannot reinterpret an accepted
261
+ // command as permission to start a second OTA transaction.
262
+ private otaStartOwnership: OtaStartOwnership | null = null
182
263
  private hasFirstActivity = false
183
264
  // Stuck-at-zero watchdog clears only on first NON-ZERO progress; "first activity"
184
265
  // alone (e.g. ota_status with stepPercent=0) used to clear it too eagerly.
@@ -206,6 +287,25 @@ class OtaInstallCoordinator {
206
287
 
207
288
  // --- public surface (engine.ota.installSession) ---
208
289
 
290
+ /** Select the transport at the existing install entry point before the progress route attaches. */
291
+ prepare(checkResult: OtaCheckCurrentGlassesResult): "wifi" | "hotspot" {
292
+ const state = useGlassesStore.getState()
293
+ if (!state.wifiStatusKnown) {
294
+ throw new Error("Glasses Wi-Fi status is not available")
295
+ }
296
+ const hotspotSupported = state.hotspotOtaVersion === 1
297
+ const wifiConnected = state.wifi.state === "connected"
298
+ if (!wifiConnected && !hotspotSupported) {
299
+ throw new Error("Connected glasses require Wi-Fi for OTA")
300
+ }
301
+ this.preparedCheckResult = checkResult
302
+ this.selectedTransport = wifiConnected ? "wifi" : "hotspot"
303
+ this.hotspotManifestUrl = null
304
+ this.hotspotPhase = "idle"
305
+ this.hotspotArtifactPercent = null
306
+ return this.selectedTransport
307
+ }
308
+
209
309
  /**
210
310
  * Bind the state machine to a mounted progress screen. Idempotent per
211
311
  * attach/detach cycle. Runs the initial-mount arbitration (send ota_start
@@ -216,6 +316,18 @@ class OtaInstallCoordinator {
216
316
  if (this.attached) return
217
317
  this.attached = true
218
318
  this.resetSessionState()
319
+ const initialState = useGlassesStore.getState()
320
+ this.protocolProfile = selectOtaProtocolProfile(
321
+ initialState.otaStatus,
322
+ initialState.otaProgress,
323
+ initialState.buildNumber,
324
+ )
325
+ const selected = initialState.otaUpdateAvailable
326
+ if (selected?.isDowngrade && typeof selected.versionCode === "number" && selected.versionCode > 0) {
327
+ this.versionChangeSession = true
328
+ this.versionChangeTarget = selected.versionCode
329
+ console.log(`[OTA_PROGRESS] version-change (downgrade) session, target=${selected.versionCode}`)
330
+ }
219
331
  this.prevConnected = isGlassesConnected(useGlassesStore.getState().connection)
220
332
  this.storeUnsubscribe = useGlassesStore.subscribe(() => this.react())
221
333
  // Mount pass: every reaction block runs once (like all effects on mount),
@@ -246,14 +358,67 @@ class OtaInstallCoordinator {
246
358
  }
247
359
  GlobalEventEmitter.off("ota_start_ack", this.handleAck)
248
360
  GlobalEventEmitter.off("mtk_update_complete", this.handleMtkComplete)
361
+ GlobalEventEmitter.off("glasses_session_changed", this.handleGlassesSessionChanged)
249
362
  this.clearAllOtaTimers()
250
363
  this.clearContinueLockoutTimer()
364
+ // Only native command ownership survives a remount. Once the command has
365
+ // settled, the glasses store/durable ota_status is the session authority.
366
+ if (this.otaStartOwnership?.outcome !== "pending") {
367
+ this.otaStartOwnership = null
368
+ }
251
369
  this.resetSessionState()
252
370
  }
253
371
 
254
372
  /** Retry after a failure: clear state and re-send ota_start (if connected). */
255
373
  retry(): void {
256
- console.log("[OTA_PROGRESS] retry pressed, clearing state and re-sending ota_start")
374
+ // While the recovery worker owns the detour (apk install latched, not yet
375
+ // converged), the phone must not drive: an ota_start now would push the
376
+ // factory/surviving build into a parallel download-install, and a second
377
+ // handoff would begin()/REPLACE the live transaction under the running
378
+ // worker. Retry just re-enters the reconcile wait — convergence or the
379
+ // session timeout decides the real outcome.
380
+ if (this.isInVersionChangeDetour()) {
381
+ console.log("[OTA_PROGRESS] retry during version-change detour — re-entering wait (no ota_start)")
382
+ this.setErrorMsg("")
383
+ this.emitInternalChange()
384
+ return
385
+ }
386
+ const store = useGlassesStore.getState()
387
+ const otaStatus = store.otaStatus
388
+ const otaProgress = store.otaProgress
389
+ const authoritativeFailure = otaStatus?.status === "failed" || otaProgress?.status === "FAILED"
390
+ const hasUnfinishedSession =
391
+ (!!otaStatus &&
392
+ otaStatus.status !== "idle" &&
393
+ otaStatus.status !== "failed" &&
394
+ otaStatus.status !== "complete") ||
395
+ (!!otaProgress && otaProgress.status !== "FAILED" && otaProgress.status !== "FINISHED")
396
+ const shouldReconcile =
397
+ this.otaStartOwnership?.outcome === "pending" ||
398
+ (!authoritativeFailure &&
399
+ (this.otaStartOwnership?.outcome === "acknowledged" || this.hasFirstActivity || hasUnfinishedSession))
400
+
401
+ if (shouldReconcile) {
402
+ console.log("[OTA_PROGRESS] retry pressed for an existing OTA session — reconciling without ota_start")
403
+ this.clearPerStepTimers()
404
+ this.clearLegacyApkSettleTimer()
405
+ this.clearMtkSimulationTimers()
406
+ this.clearContinueLockoutTimer()
407
+ this.setErrorMsg("")
408
+ this.maybeStartGlobalTimeout()
409
+ this.maybeArmStuckWatchdog()
410
+ if (isGlassesConnected(store.connection)) {
411
+ if (this.otaStartOwnership?.outcome === "pending") {
412
+ void this.sendOtaStartWithWatchdogs()
413
+ } else {
414
+ void BluetoothSdk.queryOtaStatus().catch(() => {})
415
+ this.armQueryReplyFallback("retry")
416
+ }
417
+ }
418
+ return
419
+ }
420
+
421
+ console.log("[OTA_PROGRESS] retry pressed after a rejected or terminal attempt — starting a fresh ota_start")
257
422
  // Batch like the old event handler: React ran the whole handler (including
258
423
  // the ota_start send) before re-rendering + re-running effects.
259
424
  const wasReacting = this.reacting
@@ -262,16 +427,16 @@ class OtaInstallCoordinator {
262
427
  this.clearPerStepTimers()
263
428
  this.clearLegacyApkSettleTimer()
264
429
  this.clearMtkSimulationTimers()
430
+ this.clearContinueLockoutTimer()
265
431
  this.retryCount = 0
266
- this.hasFirstActivity = false
267
- this.hasReceivedAck = false
432
+ if (!this.prepareFreshOtaStartAttempt()) return
433
+ this.resetBesRestartAttempt()
268
434
  this.setSawReconnectEdge(false)
269
435
  this.setErrorMsg("")
270
- this.setApkCompletedViaBuildIncrease(false)
436
+ this.setContinueButtonDisabled(false)
271
437
  this.setLegacyApkSettleHold(false)
272
438
  this.setMtkSimulatedPercent(null)
273
439
  this.lastRealMtkProgress = 0
274
- const store = useGlassesStore.getState()
275
440
  store.setOtaProgress(null)
276
441
  store.setOtaStatus(null)
277
442
  if (isGlassesConnected(useGlassesStore.getState().connection)) {
@@ -288,12 +453,17 @@ class OtaInstallCoordinator {
288
453
  * stays in the screen): clear the selected update, and after an APK step
289
454
  * clear the stale build number so the next check re-reads version_info.
290
455
  */
291
- finish(): void {
456
+ async finish(): Promise<void> {
457
+ if (this.otaStartOwnership?.outcome !== "pending") {
458
+ this.otaStartOwnership = null
459
+ }
292
460
  useGlassesStore.getState().setOtaUpdateAvailable(null)
293
461
  if (this.apkStepSeen) {
294
- BluetoothSdk.updateGlasses({buildNumber: ""})
295
462
  useGlassesStore.getState().setGlassesInfo({buildNumber: ""})
296
463
  }
464
+ // The post-install check needs internet again. Do not let callers leave the
465
+ // terminal screen while Android still owns the no-internet hotspot route.
466
+ await this.teardownHotspotTransport()
297
467
  }
298
468
 
299
469
  /**
@@ -302,8 +472,8 @@ class OtaInstallCoordinator {
302
472
  * deriveDisplayState doesn't resurrect the stale install when the host
303
473
  * navigates away and back through /ota routes.
304
474
  */
305
- discard(): void {
306
- this.finish()
475
+ async discard(): Promise<void> {
476
+ await this.finish()
307
477
  const store = useGlassesStore.getState()
308
478
  store.setOtaStatus(null)
309
479
  store.setOtaProgress(null)
@@ -323,17 +493,45 @@ class OtaInstallCoordinator {
323
493
  sawReconnectEdge: this.sawReconnectEdge,
324
494
  legacyApkSettleHold: this.legacyApkSettleHold,
325
495
  apkCompletedViaBuildIncrease: this.apkCompletedViaBuildIncrease,
496
+ besRestartRecovery: this.besRestartRecovery,
497
+ versionChangeConverged: this.versionChangeConverged,
498
+ versionChangeSession: this.versionChangeSession,
326
499
  }),
327
500
  errorMsg: this.errorMsg,
328
- continueButtonDisabled: this.continueButtonDisabled,
501
+ // Keep Continue disabled while the expected BES reboot is still in flight.
502
+ continueButtonDisabled: this.continueButtonDisabled || this.besRestartRecovery === "awaiting",
329
503
  connected,
330
504
  // Copies: the snapshot must not hand callers mutable references into the store.
331
505
  otaStatus: otaStatus ? {...otaStatus} : null,
332
506
  otaProgress: otaProgress ? {...otaProgress} : null,
333
507
  mtkInstallStallSimulatedPercent: this.mtkSimulatedPercent,
508
+ isVersionChange: this.versionChangeSession,
509
+ versionChangeConverged: this.versionChangeConverged,
510
+ versionChangePhase: this.deriveVersionChangePhase(connected),
511
+ hotspotPhase: this.hotspotPhase,
512
+ hotspotArtifactPercent: this.hotspotArtifactPercent,
513
+ transport: this.selectedTransport,
334
514
  }
335
515
  }
336
516
 
517
+ /**
518
+ * True while the recovery worker owns the downgrade transaction (after the apk install
519
+ * started, before convergence). During this window ASG is being uninstalled/reinstalled and
520
+ * cannot answer the phone, so the coordinator must not ping, poll ota_query_status, or arm the
521
+ * progress-stall watchdog — doing so drives the passive factory build and fails the session
522
+ * before the target reconnects. Completion is exact-version convergence, detected separately.
523
+ */
524
+ private isInVersionChangeDetour(): boolean {
525
+ return this.versionChangeSession && this.versionChangeInstallStarted && !this.versionChangeConverged
526
+ }
527
+
528
+ /** Downgrade-detour sub-phase for the progress narrative (see OtaInstallSnapshot). */
529
+ private deriveVersionChangePhase(connected: boolean): "installing" | "restarting" | "verifying" | null {
530
+ if (!this.versionChangeSession || this.versionChangeConverged) return null
531
+ if (!this.versionChangeInstallStarted) return "installing"
532
+ return connected ? "verifying" : "restarting"
533
+ }
534
+
337
535
  /**
338
536
  * Subscribe to install snapshot changes, deduped on the projected JSON.
339
537
  * Fires on glasses-store changes AND coordinator-internal state changes
@@ -367,10 +565,16 @@ class OtaInstallCoordinator {
367
565
  this.initialBuildNumber = null
368
566
  this.apkExpectedInSession = false
369
567
  this.apkCompletedViaBuildIncrease = false
568
+ this.protocolProfile = null
569
+ this.resetBesRestartAttempt()
570
+ this.versionChangeSession = false
571
+ this.versionChangeTarget = null
572
+ this.versionChangeInstallStarted = false
573
+ this.versionChangeConverged = false
370
574
  this.legacyApkSettleHold = false
371
575
  this.mtkSimulatedPercent = null
372
576
  this.lastRealMtkProgress = 0
373
- this.hasReceivedAck = false
577
+ this.apkInstallPollInFlight = false
374
578
  this.hasFirstActivity = false
375
579
  this.hasFirstNonZeroProgress = false
376
580
  this.retryCount = 0
@@ -385,6 +589,26 @@ class OtaInstallCoordinator {
385
589
  this.reactQueued = false
386
590
  }
387
591
 
592
+ /** Begin a genuinely new command attempt only after native ownership has settled. */
593
+ private prepareFreshOtaStartAttempt(): boolean {
594
+ if (this.otaStartOwnership?.outcome === "pending") {
595
+ console.error("[OTA_PROGRESS] refused fresh ota_start while native still owns the previous request")
596
+ return false
597
+ }
598
+ this.otaStartOwnership = null
599
+ this.hasFirstActivity = false
600
+ this.hasFirstNonZeroProgress = false
601
+ this.setApkCompletedViaBuildIncrease(false)
602
+ return true
603
+ }
604
+
605
+ /** Reset only the reboot lifecycle that belongs to one install attempt. */
606
+ private resetBesRestartAttempt(): void {
607
+ this.besRestartExpected = false
608
+ this.besRestartDisconnectObserved = false
609
+ this.besRestartRecovery = null
610
+ }
611
+
388
612
  /** Notify snapshot subscribers of a coordinator-internal state change. */
389
613
  private emitInternalChange(): void {
390
614
  for (const check of Array.from(this.snapshotCheckers)) check()
@@ -434,18 +658,21 @@ class OtaInstallCoordinator {
434
658
  this.emitInternalChange()
435
659
  }
436
660
 
437
- /**
438
- * Legacy-session shape (WP 8C): event shape wins once events exist; before any event
439
- * only the session-start build number can tell (falls back to the live build number
440
- * until it is captured). Old builds get the LEGACY_* padded policy durations.
441
- */
661
+ /** Legacy/unified policy is selected once, matching the old sticky route choice. */
442
662
  private isLegacySessionShapeNow(): boolean {
663
+ if (this.protocolProfile) return this.protocolProfile === "legacy"
443
664
  const state = useGlassesStore.getState()
444
- return isLegacyShapedOtaSession(
665
+ const selected = selectOtaProtocolProfile(
445
666
  state.otaStatus,
446
667
  state.otaProgress,
447
668
  this.initialBuildNumber || state.buildNumber,
448
669
  )
670
+ if (selected) {
671
+ this.protocolProfile = selected
672
+ console.log(`[OTA_PROGRESS] protocol profile selected: ${selected}`)
673
+ return selected === "legacy"
674
+ }
675
+ return isLegacyShapedOtaSession(state.otaStatus, state.otaProgress, state.buildNumber)
449
676
  }
450
677
 
451
678
  private react(): void {
@@ -498,6 +725,19 @@ class OtaInstallCoordinator {
498
725
 
499
726
  const legacySession = this.isLegacySessionShapeNow()
500
727
 
728
+ if (this.isBesRestartExpected(otaStatus, otaProgress, legacySession)) {
729
+ this.besRestartExpected = true
730
+ }
731
+
732
+ // Port the legacy route's actual reboot invariant before deriving display
733
+ // state, so reconnecting with stale BES in_progress@100 never renders updating.
734
+ if (connectedChanged && !connected && this.besRestartExpected && this.besRestartRecovery === null) {
735
+ this.besRestartDisconnectObserved = true
736
+ this.besRestartRecovery = "awaiting"
737
+ console.log("[OTA_PROGRESS] BES restart disconnect observed — awaiting reconnect verification")
738
+ }
739
+ if (this.besRestartRecovery === "awaiting" && connected) this.completeBesRestartRecovery()
740
+
501
741
  // Derived UI state — the glasses data IS the source of truth.
502
742
  const displayState = deriveDisplayState({
503
743
  otaStatus,
@@ -507,6 +747,9 @@ class OtaInstallCoordinator {
507
747
  sawReconnectEdge: this.sawReconnectEdge,
508
748
  legacyApkSettleHold: this.legacyApkSettleHold,
509
749
  apkCompletedViaBuildIncrease: this.apkCompletedViaBuildIncrease,
750
+ besRestartRecovery: this.besRestartRecovery,
751
+ versionChangeConverged: this.versionChangeConverged,
752
+ versionChangeSession: this.versionChangeSession,
510
753
  })
511
754
  const stallSig = buildProgressStalenessSignature(otaStatus, otaProgress, displayState)
512
755
  const stallDuration = progressTimeoutDurationMs(otaStatus, otaProgress, legacySession)
@@ -572,6 +815,73 @@ class OtaInstallCoordinator {
572
815
  useGlassesStore.getState().setMtkUpdatedThisSession(true)
573
816
  }
574
817
 
818
+ // Version-change (downgrade) install started: once ASG reports the apk install
819
+ // phase, the handoff to the recovery worker has happened. From here the phone
820
+ // stops driving ota_start (see runConnectEdge) and waits for exact-version
821
+ // convergence — the wipe means no resumable ASG session and no build increase.
822
+ if (
823
+ this.versionChangeSession &&
824
+ !this.versionChangeInstallStarted &&
825
+ // Only latch from a status that belongs to THIS session: an install/apk status is a
826
+ // response to our own acknowledged ota_start, so a leftover from a prior session
827
+ // sitting in the store at mount cannot prematurely enter the detour wait (which would
828
+ // suppress ota_start and hang the fresh downgrade until the global timeout).
829
+ this.otaStartOwnership?.outcome === "acknowledged" &&
830
+ otaStatus?.stepType === "apk" &&
831
+ otaStatus.phase === "install"
832
+ ) {
833
+ console.log("[OTA_PROGRESS] version-change: apk install started — entering detour wait")
834
+ this.versionChangeInstallStarted = true
835
+ this.emitInternalChange()
836
+ }
837
+
838
+ // Ownership refuted: ASG emits install/STARTED BEFORE handing off, so the latch above can
839
+ // be set even when no transaction ends up owning the detour. Recovery answers every
840
+ // handoff synchronously, so exactly three error codes prove non-ownership and release the
841
+ // latch: downgrade_handoff_refused (recovery's explicit verdict),
842
+ // downgrade_handoff_failed (no verdict at all — recovery dead/missing, so it never began),
843
+ // and downgrade_transaction_stalled (the long-stop past recovery's own stale give-up).
844
+ // An accepted-but-slow transaction emits NONE of these (acceptance cancels the short
845
+ // watchdog), so the latch is never released while a live worker owns the staged artifact
846
+ // — which recovery additionally claims by rename at acceptance.
847
+ if (
848
+ this.versionChangeInstallStarted &&
849
+ !this.versionChangeConverged &&
850
+ otaStatus?.status === "failed" &&
851
+ (otaStatus.error === "downgrade_handoff_refused" ||
852
+ otaStatus.error === "downgrade_handoff_failed" ||
853
+ otaStatus.error === "downgrade_transaction_stalled")
854
+ ) {
855
+ console.log(
856
+ `[OTA_PROGRESS] version-change: no transaction owns the detour (${otaStatus.error}) — releasing latch`,
857
+ )
858
+ this.versionChangeInstallStarted = false
859
+ this.emitInternalChange()
860
+ }
861
+
862
+ // Version-change convergence: the reconnected glasses report exactly the pinned
863
+ // target. This is the ONLY completion signal for a downgrade (lower build, no
864
+ // increase, wiped session). Mirrors the legacy build-increase latch but by equality.
865
+ if (
866
+ this.versionChangeSession &&
867
+ this.versionChangeInstallStarted &&
868
+ !this.versionChangeConverged &&
869
+ this.versionChangeTarget !== null &&
870
+ buildNumberChanged &&
871
+ buildNumber
872
+ ) {
873
+ const currentBuild = Number.parseInt(buildNumber, 10)
874
+ if (Number.isFinite(currentBuild) && currentBuild === this.versionChangeTarget) {
875
+ console.log(`[OTA_PROGRESS] version-change converged: glasses report target ${currentBuild}, completing`)
876
+ this.clearPerStepTimers()
877
+ this.clearGlobalTimeout()
878
+ this.apkStepSeen = true
879
+ this.setErrorMsg("")
880
+ this.versionChangeConverged = true
881
+ this.emitInternalChange()
882
+ }
883
+ }
884
+
575
885
  // Legacy APK completion by build-number increase (WP 8C-c): old builds reboot into
576
886
  // the new build without a reliable explicit reconnect status; the fresh version_info
577
887
  // build number is the completion signal (ported from progress-legacy.tsx, which
@@ -614,7 +924,7 @@ class OtaInstallCoordinator {
614
924
 
615
925
  /**
616
926
  * Connect-edge — the only place that decides to send ota_start /
617
- * ota_query_status, handles POST_APK delay, and flips sawReconnectEdge
927
+ * ota_query_status and flips sawReconnectEdge
618
928
  * on the false -> true transition that signals the BES reboot completed.
619
929
  */
620
930
  if (connectedChanged) {
@@ -628,23 +938,26 @@ class OtaInstallCoordinator {
628
938
  if (isMount) {
629
939
  GlobalEventEmitter.on("ota_start_ack", this.handleAck)
630
940
  GlobalEventEmitter.on("mtk_update_complete", this.handleMtkComplete)
941
+ GlobalEventEmitter.on("glasses_session_changed", this.handleGlassesSessionChanged)
631
942
  }
632
943
 
633
944
  // Ping keepalive while an OTA is actively running (legacy sessions pinged on the
634
945
  // padded interval — old builds slept less eagerly and pings are costlier for them).
635
946
  if (connectedChanged || displayStateChanged) {
636
947
  this.clearPingInterval()
637
- const active = connected && (displayState === "starting" || displayState === "updating")
948
+ const active =
949
+ connected && (displayState === "starting" || displayState === "updating") && !this.isInVersionChangeDetour()
638
950
  if (active) {
639
951
  void BluetoothSdk.ping().catch(() => {})
640
952
  const pingIntervalMs = legacySession ? LEGACY_PING_INTERVAL_MS : PING_INTERVAL_MS
641
953
  this.pingInterval = setInterval(() => {
642
954
  void BluetoothSdk.ping().catch(() => {})
955
+ this.maybePollApkInstallStatus()
643
956
  }, pingIntervalMs)
644
957
  }
645
958
  }
646
959
 
647
- // Any glasses activity (ota_status or otaProgress) clears the no-ack retry watchdog.
960
+ // Any glasses activity proves the logical ota_start was accepted.
648
961
  if ((otaStatusChanged || otaProgressChanged) && (otaStatus || otaProgress)) {
649
962
  this.onFirstActivity()
650
963
  }
@@ -667,7 +980,10 @@ class OtaInstallCoordinator {
667
980
  // update that doesn't change the signature keeps the same timer running
668
981
  // instead of forever re-extending the deadline.
669
982
  if (stallSigChanged || displayStateChanged) {
670
- if (displayState !== "starting" && displayState !== "updating") {
983
+ if (this.isInVersionChangeDetour()) {
984
+ // Recovery worker owns the transaction; there are no progress events to stall on.
985
+ this.clearProgressTimeout()
986
+ } else if (displayState !== "starting" && displayState !== "updating") {
671
987
  this.clearProgressTimeout()
672
988
  } else if (!stallSig) {
673
989
  this.clearProgressTimeout()
@@ -685,20 +1001,24 @@ class OtaInstallCoordinator {
685
1001
 
686
1002
  // Lockout on Continue button after BES restart to prevent accidental tap
687
1003
  // (15s unified, 35s for legacy-shaped sessions — WP 8C-f).
688
- if (displayStateChanged) {
1004
+ if (displayStateChanged && displayState === "restarting") {
689
1005
  this.clearContinueLockoutTimer()
690
- if (displayState === "restarting") {
691
- const lockoutMs = legacySession ? LEGACY_BES_CONTINUE_LOCKOUT_MS : BES_CONTINUE_LOCKOUT_MS
692
- this.setContinueButtonDisabled(true)
693
- this.continueLockoutTimer = setTimeout(() => {
694
- this.continueLockoutTimer = null
695
- this.setContinueButtonDisabled(false)
696
- }, lockoutMs)
697
- }
698
- }
699
-
700
- if (displayStateChanged && displayState === "failed") {
701
- this.clearPerStepTimers()
1006
+ this.clearRestartRecoveryTimeout()
1007
+ const lockoutMs = legacySession ? LEGACY_BES_CONTINUE_LOCKOUT_MS : BES_CONTINUE_LOCKOUT_MS
1008
+ const restartTimeoutMs = legacySession ? LEGACY_BES_RESTART_TIMEOUT_MS : BES_RESTART_TIMEOUT_MS
1009
+ this.setContinueButtonDisabled(true)
1010
+ this.continueLockoutTimer = setTimeout(() => {
1011
+ this.continueLockoutTimer = null
1012
+ this.setContinueButtonDisabled(false)
1013
+ }, lockoutMs)
1014
+ this.restartRecoveryTimeout = setTimeout(() => {
1015
+ this.restartRecoveryTimeout = null
1016
+ if (this.computeDisplayStateNow() !== "restarting") return
1017
+ console.log(`[OTA_PROGRESS] watchdog: glasses did not finish restarting in ${restartTimeoutMs}ms`)
1018
+ this.setErrorMsg(OtaProgressMessages.restartTimeout)
1019
+ }, restartTimeoutMs)
1020
+ } else if (displayStateChanged) {
1021
+ this.clearRestartRecoveryTimeout()
702
1022
  }
703
1023
 
704
1024
  // Legacy MTK install stall simulation (WP 8C-e): display-only. The simulation
@@ -716,8 +1036,54 @@ class OtaInstallCoordinator {
716
1036
  displayStateChanged &&
717
1037
  (displayState === "complete" || displayState === "restarting" || displayState === "failed")
718
1038
  ) {
1039
+ if (displayState === "failed") {
1040
+ this.clearPerStepTimers()
1041
+ }
719
1042
  useGlassesStore.getState().setOtaUpdateAvailable(null)
1043
+ if (displayState === "complete" || displayState === "failed") {
1044
+ void this.teardownHotspotTransport()
1045
+ }
1046
+ }
1047
+ }
1048
+
1049
+ /**
1050
+ * Reconnect arbitration shared by the physical BLE connect edge and the
1051
+ * glasses_session_changed signal. The latter exists because the BES keeps the BLE
1052
+ * link alive across asg_client restarts (APK OTA): no physical edge ever fires, so
1053
+ * the changed process session id is the only reconnect signal the coordinator gets
1054
+ * (incident rep_01KY6BJ0B7A4RBMQ7VN39KAE5E). Always returns true (the caller's
1055
+ * edge handling is complete).
1056
+ */
1057
+ private runReconnectArbitration(label: string): boolean {
1058
+ console.log(`[OTA_PROGRESS] ${label}: false->true, flipping sawReconnectEdge=true`)
1059
+ // A physical reconnect passed through the disconnect branch first, which cleared
1060
+ // these timers; a session-change edge never disconnects, so a fallback armed by an
1061
+ // earlier mount/query could otherwise fire alongside the arbitration below and send
1062
+ // a duplicate ota_start. Clearing here is a no-op on the physical path.
1063
+ this.clearQueryReplyTimeout()
1064
+ this.setSawReconnectEdge(true)
1065
+
1066
+ if (this.besRestartDisconnectObserved) {
1067
+ console.log(`[OTA_PROGRESS] ${label}: BES reboot reconnect observed — no ota_query_status`)
1068
+ this.completeBesRestartRecovery()
1069
+ return true
1070
+ }
1071
+
1072
+ // Legacy apk settle window (WP 8C): the glasses restarting into the new build is
1073
+ // the EXPECTED reconnect here — the legacy screen took no action on it. Skip the
1074
+ // query/fallback; the settle timer (or the build-number increase) completes.
1075
+ if (this.legacyApkSettleHold) {
1076
+ console.log(`[OTA_PROGRESS] ${label}: reconnect during legacy apk settle hold — no query`)
1077
+ return true
1078
+ }
1079
+
1080
+ console.log(`[OTA_PROGRESS] ${label}: reconnected, sending ota_query_status`)
1081
+ if (this.otaStartOwnership?.outcome === "pending") {
1082
+ void this.sendOtaStartWithWatchdogs()
720
1083
  }
1084
+ void BluetoothSdk.queryOtaStatus().catch(() => {})
1085
+ this.armQueryReplyFallback("reconnect")
1086
+ return true
721
1087
  }
722
1088
 
723
1089
  private runConnectEdge(connected: boolean): void {
@@ -726,54 +1092,30 @@ class OtaInstallCoordinator {
726
1092
 
727
1093
  if (!connected) {
728
1094
  console.log("[OTA_PROGRESS] connect-edge: disconnected")
729
- this.clearPostApkDelay()
730
- // Also drop a pending query-reply fallback: firing ota_start into a dead
731
- // link can only produce a false "failed" state, and the reconnect edge
732
- // re-queries and re-arms this fallback anyway.
1095
+ // Also drop pending query/retry sends: firing ota_start into a dead link
1096
+ // can only produce a false "failed" state. Reconnect arbitration first
1097
+ // queries durable status and re-arms the one permitted fallback.
733
1098
  this.clearQueryReplyTimeout()
1099
+ this.clearRetryTimeout()
734
1100
  return
735
1101
  }
736
1102
 
737
1103
  const becameConnected = prev === false && connected === true
738
- if (becameConnected) {
739
- console.log("[OTA_PROGRESS] connect-edge: false->true, flipping sawReconnectEdge=true")
740
- this.setSawReconnectEdge(true)
741
- }
742
-
743
- // Legacy apk settle window (WP 8C): the glasses restarting into the new build is
744
- // the EXPECTED reconnect here — the legacy screen took no action on it. Skip the
745
- // query/fallback; the settle timer (or the build-number increase) completes.
746
- if (becameConnected && this.legacyApkSettleHold) {
747
- console.log("[OTA_PROGRESS] connect-edge: reconnect during legacy apk settle hold — no query")
1104
+ if (becameConnected && this.runReconnectArbitration("connect-edge")) {
748
1105
  return
749
1106
  }
750
1107
 
751
- const storeState = useGlassesStore.getState()
752
- const s = storeState.otaStatus
753
- const postApkAwaiting =
754
- s?.stepType === "apk" && (s?.totalSteps ?? 0) > 1 && (s.status === "step_complete" || s.status === "complete")
755
-
756
- if (becameConnected && postApkAwaiting) {
757
- console.log("[OTA_PROGRESS] connect-edge: post-APK reboot detected, arming POST_APK delay for sendOtaStart")
758
- this.clearPostApkDelay()
759
- this.postApkDelay = setTimeout(() => {
760
- this.postApkDelay = null
761
- this.retryCount = 0
762
- this.hasFirstActivity = false
763
- this.hasFirstNonZeroProgress = false
764
- this.hasReceivedAck = false
765
- console.log("[OTA_PROGRESS] POST_APK delay fired, sending ota_start")
766
- void this.sendOtaStartWithWatchdogs()
767
- }, POST_APK_OTA_START_DELAY_MS)
1108
+ // Version-change detour: after the install started, EVERY reconnect is expected
1109
+ // (the passive factory build, then the target). Do NOT send ota_start/query — the
1110
+ // factory build would treat the pin as a plain upgrade and start a redundant
1111
+ // download racing the recovery worker. Convergence (buildNumber === target) is
1112
+ // detected in the reaction pass; here we simply take no action.
1113
+ if (this.versionChangeSession && this.versionChangeInstallStarted && !this.versionChangeConverged) {
1114
+ console.log("[OTA_PROGRESS] connect-edge: reconnect during version-change detour — no query (expected)")
768
1115
  return
769
1116
  }
770
1117
 
771
- if (becameConnected) {
772
- console.log("[OTA_PROGRESS] connect-edge: reconnected, sending ota_query_status")
773
- void BluetoothSdk.sendOtaQueryStatus()
774
- this.armQueryReplyFallback("reconnect")
775
- return
776
- }
1118
+ const storeState = useGlassesStore.getState()
777
1119
 
778
1120
  // Initial mount (prev === current === true). If no session yet, kick off ota_start.
779
1121
  // An idle ota_status has an empty sessionId; treat it as no session so ota_start
@@ -781,19 +1123,25 @@ class OtaInstallCoordinator {
781
1123
  const isIdleStatus = !!storeState.otaStatus && storeState.otaStatus.sessionId === ""
782
1124
  const noSessionYet = (!storeState.otaStatus && !storeState.otaProgress) || isIdleStatus
783
1125
  if (noSessionYet) {
1126
+ if (!isIdleStatus && this.otaStartOwnership?.outcome === "acknowledged") {
1127
+ console.log("[OTA_PROGRESS] initial mount, acknowledged session has no cached status — reconciling")
1128
+ void BluetoothSdk.queryOtaStatus().catch(() => {})
1129
+ this.armQueryReplyFallback("initial-mount")
1130
+ return
1131
+ }
784
1132
  console.log(
785
1133
  isIdleStatus
786
1134
  ? "[OTA_PROGRESS] initial mount, idle status (no sessionId), sending ota_start"
787
1135
  : "[OTA_PROGRESS] initial mount, no session in store, sending ota_start",
788
1136
  )
789
1137
  this.retryCount = 0
790
- this.hasFirstActivity = false
791
- this.hasFirstNonZeroProgress = false
792
- this.hasReceivedAck = false
1138
+ if (isIdleStatus && this.otaStartOwnership?.outcome !== "pending" && !this.prepareFreshOtaStartAttempt()) {
1139
+ return
1140
+ }
793
1141
  void this.sendOtaStartWithWatchdogs()
794
1142
  } else {
795
1143
  console.log("[OTA_PROGRESS] initial mount, session exists, sending ota_query_status")
796
- void BluetoothSdk.sendOtaQueryStatus()
1144
+ void BluetoothSdk.queryOtaStatus().catch(() => {})
797
1145
  this.armQueryReplyFallback("initial-mount")
798
1146
  }
799
1147
  }
@@ -801,18 +1149,28 @@ class OtaInstallCoordinator {
801
1149
  // --- BLE OTA event listeners (engine GlobalEventEmitter, fed by OtaService) ---
802
1150
 
803
1151
  private readonly handleAck = (): void => {
804
- if (this.hasReceivedAck) return
1152
+ const ownership = this.otaStartOwnership
1153
+ if (!ownership || ownership.outcome === "rejected") return
1154
+ if (ownership.outcome === "acknowledged") return
805
1155
  console.log("[OTA_PROGRESS] ota_start_ack received")
806
- this.hasReceivedAck = true
1156
+ ownership.outcome = "acknowledged"
807
1157
  this.clearRetryTimeout()
808
1158
  }
809
1159
 
1160
+ /**
1161
+ * The asg process restarted while the BES kept the BLE link up (sid change observed
1162
+ * by the bridge): treat it exactly like a physical reconnect edge.
1163
+ */
1164
+ private readonly handleGlassesSessionChanged = (): void => {
1165
+ this.runReconnectArbitration("session-change")
1166
+ }
1167
+
810
1168
  private readonly handleMtkComplete = (): void => {
811
1169
  console.log("[OTA_PROGRESS] mtk_update_complete received")
812
1170
  this.clearProgressTimeout()
813
1171
  this.onFirstActivity()
814
1172
  this.onFirstNonZeroProgress()
815
- void BluetoothSdk.sendOtaQueryStatus()
1173
+ void BluetoothSdk.queryOtaStatus().catch(() => {})
816
1174
  useGlassesStore.getState().setMtkUpdatedThisSession(true)
817
1175
  }
818
1176
 
@@ -834,7 +1192,9 @@ class OtaInstallCoordinator {
834
1192
  }
835
1193
 
836
1194
  if (this.errorMsg) {
837
- console.log("[OTA_PROGRESS] legacy install complete arrived after a failure — completing (legacy last-write-wins)")
1195
+ console.log(
1196
+ "[OTA_PROGRESS] legacy install complete arrived after a failure — completing (legacy last-write-wins)",
1197
+ )
838
1198
  this.setErrorMsg("")
839
1199
  this.clearLegacyApkSettleTimer()
840
1200
  this.setLegacyApkSettleHold(false)
@@ -860,6 +1220,43 @@ class OtaInstallCoordinator {
860
1220
  }
861
1221
  }
862
1222
 
1223
+ private isBesRestartExpected(
1224
+ otaStatus: OtaStatus | null,
1225
+ otaProgress: OtaProgress | null,
1226
+ legacySession: boolean,
1227
+ ): boolean {
1228
+ const statusReadyToRestart =
1229
+ otaStatus?.stepType === "bes" &&
1230
+ otaStatus.phase === "install" &&
1231
+ (otaStatus.status === "step_complete" || otaStatus.status === "complete")
1232
+ const progressReadyToRestart =
1233
+ legacySession &&
1234
+ otaProgress?.currentUpdate === "bes" &&
1235
+ otaProgress.stage === "install" &&
1236
+ otaProgress.status === "FINISHED"
1237
+
1238
+ // progress-legacy treated a BES disconnect during active installation as the
1239
+ // expected power cycle even when its terminal FINISHED event was lost. Keep
1240
+ // that fallback only for the sticky pre-37 profile selected when the flow began.
1241
+ const legacyInstallInProgress =
1242
+ legacySession &&
1243
+ ((otaStatus?.stepType === "bes" && otaStatus.phase === "install" && otaStatus.status === "in_progress") ||
1244
+ (otaProgress?.currentUpdate === "bes" &&
1245
+ otaProgress.stage === "install" &&
1246
+ (otaProgress.status === "STARTED" || otaProgress.status === "PROGRESS")))
1247
+ return statusReadyToRestart || progressReadyToRestart || legacyInstallInProgress
1248
+ }
1249
+
1250
+ /** Complete only after a protocol-authorized BES restart traverses disconnect -> reconnect. */
1251
+ private completeBesRestartRecovery(): void {
1252
+ if (!this.besRestartDisconnectObserved || this.besRestartRecovery !== "awaiting") return
1253
+ console.log("[OTA_PROGRESS] BES restart reconnected — completing by reboot edge")
1254
+ this.clearPerStepTimers()
1255
+ this.setErrorMsg("")
1256
+ this.besRestartRecovery = "complete"
1257
+ this.emitInternalChange()
1258
+ }
1259
+
863
1260
  /**
864
1261
  * Legacy MTK install stall simulation (WP 8C-e), ported verbatim: the MTK system
865
1262
  * install typically stalls around 49-50%. Every real event restarts stall detection;
@@ -935,17 +1332,54 @@ class OtaInstallCoordinator {
935
1332
  sawReconnectEdge: this.sawReconnectEdge,
936
1333
  legacyApkSettleHold: this.legacyApkSettleHold,
937
1334
  apkCompletedViaBuildIncrease: this.apkCompletedViaBuildIncrease,
1335
+ besRestartRecovery: this.besRestartRecovery,
1336
+ versionChangeConverged: this.versionChangeConverged,
1337
+ versionChangeSession: this.versionChangeSession,
938
1338
  })
939
1339
  }
940
1340
 
941
1341
  /**
942
- * "Glasses are talking to us" — clears the no-ack retry watchdog only.
1342
+ * During an APK install the package installer kills and restarts the glasses
1343
+ * process, and the new process's completion push can lose its startup race
1344
+ * against the UART transport (incident rep_01KY31HEMTSBSMK8DVMNXJ5XGG: the
1345
+ * phone sat on a stale "install in_progress 0%" until the stall watchdog
1346
+ * failed a successful update). The persisted session on the glasses always
1347
+ * knows the truth, so poll ota_query_status on the keepalive tick while an
1348
+ * apk step sits in its install phase — the reply either confirms progress or
1349
+ * delivers the missed completion. Gated to the apk install phase so a stray
1350
+ * idle reply can't disturb an active download; legacy (< 37) builds ignore
1351
+ * the query, which is harmless.
1352
+ *
1353
+ * The native query pends up to 15s and rejects a concurrent call with
1354
+ * request_in_flight — longer than the 10s tick — so exactly one poll is kept
1355
+ * in flight (the glasses being slow to answer IS the silent-restart window
1356
+ * this poll exists for) and rejections are swallowed: the next tick retries.
1357
+ */
1358
+ private maybePollApkInstallStatus(): void {
1359
+ if (this.isInVersionChangeDetour()) return
1360
+ if (this.apkInstallPollInFlight) return
1361
+ const s = useGlassesStore.getState().otaStatus
1362
+ if (s?.stepType === "apk" && s.phase === "install" && s.status === "in_progress") {
1363
+ this.apkInstallPollInFlight = true
1364
+ void BluetoothSdk.queryOtaStatus()
1365
+ .catch(() => {})
1366
+ .finally(() => {
1367
+ this.apkInstallPollInFlight = false
1368
+ })
1369
+ }
1370
+ }
1371
+
1372
+ /**
1373
+ * "Glasses are talking to us" — suppresses retry if the native request later rejects.
943
1374
  * Important: this does NOT clear the stuck-at-zero watchdog; that one fires
944
1375
  * on real progress > 0% (see {@link onFirstNonZeroProgress}).
945
1376
  */
946
1377
  private onFirstActivity(): void {
947
1378
  if (this.hasFirstActivity) return
948
1379
  this.hasFirstActivity = true
1380
+ if (this.otaStartOwnership?.outcome === "pending") {
1381
+ this.otaStartOwnership.outcome = "acknowledged"
1382
+ }
949
1383
  this.clearRetryTimeout()
950
1384
  }
951
1385
 
@@ -968,7 +1402,8 @@ class OtaInstallCoordinator {
968
1402
  // Legacy-shape check happens once, when the session cap is armed at the first
969
1403
  // ota_start send (WP 8C-b): old builds get the padded legacy cap for the whole
970
1404
  // session; a session that starts unified keeps the unified cap.
971
- const globalTimeoutMs = this.isLegacySessionShapeNow() ? LEGACY_GLOBAL_OTA_TIMEOUT_MS : GLOBAL_OTA_TIMEOUT_MS
1405
+ const globalTimeoutMs =
1406
+ this.versionChangeSession || this.isLegacySessionShapeNow() ? LEGACY_GLOBAL_OTA_TIMEOUT_MS : GLOBAL_OTA_TIMEOUT_MS
972
1407
  this.globalTimeout = setTimeout(() => {
973
1408
  this.globalTimeout = null
974
1409
  this.globalTimeoutStarted = false
@@ -984,37 +1419,14 @@ class OtaInstallCoordinator {
984
1419
  }, globalTimeoutMs)
985
1420
  }
986
1421
 
987
- private armAckAndStuckWatchdogsOnly(): void {
988
- this.clearRetryTimeout()
1422
+ private armStuckWatchdogOnly(): void {
989
1423
  this.clearStuckTimeout()
990
1424
 
991
1425
  // Durations resolved at arm time (WP 8C-b): legacy-shaped sessions get the
992
1426
  // padded values progress-legacy.tsx used for old (< 37) builds.
993
1427
  const legacySession = this.isLegacySessionShapeNow()
994
- const retryIntervalMs = legacySession ? LEGACY_RETRY_INTERVAL_MS : RETRY_INTERVAL_MS
995
1428
  const stuckTimeoutMs = legacySession ? LEGACY_DOWNLOAD_STUCK_TIMEOUT_MS : DOWNLOAD_STUCK_TIMEOUT_MS
996
1429
 
997
- this.retryTimeout = setTimeout(() => {
998
- this.retryTimeout = null
999
- if (this.hasReceivedAck || this.hasFirstActivity) return
1000
- if (this.computeDisplayStateNow() !== "starting") return
1001
- if (this.retryCount < MAX_RETRIES - 1) {
1002
- this.retryCount += 1
1003
- this.hasReceivedAck = false
1004
- console.log(
1005
- `[OTA_PROGRESS] watchdog: no ack in ${retryIntervalMs}ms, retrying ota_start (attempt ${this.retryCount})`,
1006
- )
1007
- void this.sendOtaStartWithWatchdogs(true)
1008
- .then(() => {
1009
- this.armAckAndStuckWatchdogsOnly()
1010
- })
1011
- .catch(() => {})
1012
- } else {
1013
- console.log(`[OTA_PROGRESS] watchdog: ota_start ack never received after ${MAX_RETRIES} attempts, failing`)
1014
- this.setErrorMsg(OtaProgressMessages.noAckResponse)
1015
- }
1016
- }, retryIntervalMs)
1017
-
1018
1430
  this.stuckTimeout = setTimeout(() => {
1019
1431
  this.stuckTimeout = null
1020
1432
  const d = this.computeDisplayStateNow()
@@ -1030,26 +1442,130 @@ class OtaInstallCoordinator {
1030
1442
  }, stuckTimeoutMs)
1031
1443
  }
1032
1444
 
1033
- private async sendOtaStartWithWatchdogs(retryAlreadyCounted = false): Promise<void> {
1445
+ /** Arm the 0%-stuck watchdog only when this logical session has none. */
1446
+ private maybeArmStuckWatchdog(): void {
1447
+ if (this.stuckTimeout || this.hasFirstNonZeroProgress) return
1448
+ this.armStuckWatchdogOnly()
1449
+ }
1450
+
1451
+ private sendOtaStartWithWatchdogs(): Promise<void> {
1452
+ // INVARIANT BACKSTOP — not normal control flow. Every entry point that can drive the
1453
+ // glasses (connect-edge, query fallback, retry, mount) carries its own detour gate with the
1454
+ // right behavior for that site; this final check only exists so that a FUTURE entry point
1455
+ // that forgets its gate degrades to a refused send + loud log instead of a corrupted
1456
+ // transaction (an ota_start mid-detour starts a parallel install on the factory build and
1457
+ // can trigger a second handoff). If this ever fires, a missed site gate exists — fix it.
1458
+ if (this.isInVersionChangeDetour()) {
1459
+ console.error(
1460
+ "[OTA_PROGRESS] BACKSTOP: refused ota_start during a latched version-change detour — a caller is missing its detour gate",
1461
+ )
1462
+ return Promise.resolve()
1463
+ }
1464
+ const existingOwnership = this.otaStartOwnership
1465
+ if (existingOwnership?.outcome === "pending") {
1466
+ this.maybeStartGlobalTimeout()
1467
+ if (this.selectedTransport === "wifi" || this.hotspotManifestUrl) this.maybeArmStuckWatchdog()
1468
+ console.log("[OTA_PROGRESS] ota_start already pending — adopting existing native request")
1469
+ return existingOwnership.promise
1470
+ }
1471
+ if (existingOwnership?.outcome === "acknowledged") {
1472
+ this.maybeStartGlobalTimeout()
1473
+ this.maybeArmStuckWatchdog()
1474
+ console.log("[OTA_PROGRESS] ota_start already acknowledged — refusing duplicate start")
1475
+ return Promise.resolve()
1476
+ }
1477
+ if (existingOwnership?.outcome === "rejected" && !this.prepareFreshOtaStartAttempt()) {
1478
+ return Promise.resolve()
1479
+ }
1034
1480
  this.maybeStartGlobalTimeout()
1035
- this.hasReceivedAck = false
1036
- this.armAckAndStuckWatchdogsOnly()
1481
+ if (this.selectedTransport === "wifi" || this.hotspotManifestUrl) this.maybeArmStuckWatchdog()
1482
+
1483
+ const ownership: OtaStartOwnership = {
1484
+ outcome: "pending",
1485
+ promise: Promise.resolve(),
1486
+ }
1487
+ ownership.promise = this.performOtaStart(ownership)
1488
+ this.otaStartOwnership = ownership
1489
+ return ownership.promise
1490
+ }
1491
+
1492
+ private async performOtaStart(ownership: OtaStartOwnership): Promise<void> {
1493
+ let nativeStartAttempted = false
1037
1494
  try {
1038
1495
  const state = useGlassesStore.getState()
1039
- const otaVersionUrl = getAsgOtaVersionUrl(state.otaVersionUrl, state.buildNumber)
1040
- console.log(`[OTA_PROGRESS] sending ota_start with manifest URL: ${otaVersionUrl}`)
1496
+ let otaVersionUrl = resolveOtaManifestUrl(state.otaVersionUrl, state.buildNumber)
1497
+ if (this.selectedTransport === "hotspot") {
1498
+ if (!this.hotspotManifestUrl) {
1499
+ if (!this.preparedCheckResult) {
1500
+ throw new Error("No selected OTA check is available for hotspot staging")
1501
+ }
1502
+ this.hotspotManifestUrl = await hotspotOtaTransport.prepare(this.preparedCheckResult, (progress) => {
1503
+ this.hotspotPhase = progress.phase
1504
+ this.hotspotArtifactPercent = progress.artifact?.artifactPercent ?? null
1505
+ this.emitInternalChange()
1506
+ })
1507
+ }
1508
+ otaVersionUrl = this.hotspotManifestUrl
1509
+ this.maybeArmStuckWatchdog()
1510
+ }
1511
+ if (!otaVersionUrl) {
1512
+ throw new Error("OTA is disabled because this build has no immutable manifest pin")
1513
+ }
1514
+ console.log(`[OTA_PROGRESS] sending ota_start with ${this.selectedTransport} manifest URL: ${otaVersionUrl}`)
1515
+ nativeStartAttempted = true
1041
1516
  await BluetoothSdk.startOtaUpdate(otaVersionUrl)
1517
+ if (this.otaStartOwnership !== ownership) return
1518
+ // The public SDK promise resolves only from ota_start_ack. Treat that
1519
+ // resolution as the acknowledgement even if the parallel event dispatch
1520
+ // reaches the coordinator later (or is dropped by a remount).
1521
+ this.handleAck()
1042
1522
  } catch (err) {
1043
1523
  console.warn("[OTA_PROGRESS] sendOtaStart threw", err)
1044
- this.clearRetryTimeout()
1524
+
1525
+ if (this.otaStartOwnership !== ownership) return
1526
+
1527
+ if (this.selectedTransport === "hotspot" && !nativeStartAttempted) {
1528
+ ownership.outcome = "rejected"
1529
+ this.clearStuckTimeout()
1530
+ await this.teardownHotspotTransport()
1531
+ if (this.attached) this.setErrorMsg(hotspotPreflightErrorMessage(err))
1532
+ return
1533
+ }
1534
+
1535
+ // A received ota_status/legacy progress event, or the exact legacy APK
1536
+ // build-number proof, proves that this logical start was accepted. Its
1537
+ // application-level ack may be lost while the update continues; never
1538
+ // create another transaction or fail a live, restarting, or completed
1539
+ // session because that stale promise timed out.
1540
+ if (this.hasFirstActivity || this.apkCompletedViaBuildIncrease) {
1541
+ ownership.outcome = "acknowledged"
1542
+ console.log("[OTA_PROGRESS] ota_start rejection arrived after acceptance proof — ignoring stale rejection")
1543
+ return
1544
+ }
1545
+
1546
+ ownership.outcome = "rejected"
1547
+ if (!this.attached) return
1045
1548
  this.clearStuckTimeout()
1549
+ if (!isGlassesConnected(useGlassesStore.getState().connection)) {
1550
+ console.log(
1551
+ "[OTA_PROGRESS] ota_start request ended while disconnected; deferring retry to reconnect arbitration",
1552
+ )
1553
+ return
1554
+ }
1046
1555
  if (this.retryCount < MAX_RETRIES - 1) {
1047
- if (!retryAlreadyCounted) {
1048
- this.retryCount += 1
1049
- }
1556
+ this.retryCount += 1
1050
1557
  const retryIntervalMs = this.isLegacySessionShapeNow() ? LEGACY_RETRY_INTERVAL_MS : RETRY_INTERVAL_MS
1558
+ console.log(
1559
+ `[OTA_PROGRESS] ota_start request ended without ack or activity; retrying after ${retryIntervalMs}ms (attempt ${
1560
+ this.retryCount + 1
1561
+ }/${MAX_RETRIES})`,
1562
+ )
1051
1563
  this.retryTimeout = setTimeout(() => {
1052
1564
  this.retryTimeout = null
1565
+ if (!isGlassesConnected(useGlassesStore.getState().connection)) {
1566
+ console.log("[OTA_PROGRESS] ota_start retry paused while disconnected; reconnect arbitration will resume")
1567
+ return
1568
+ }
1053
1569
  void this.sendOtaStartWithWatchdogs()
1054
1570
  }, retryIntervalMs)
1055
1571
  } else {
@@ -1059,18 +1575,28 @@ class OtaInstallCoordinator {
1059
1575
  }
1060
1576
  }
1061
1577
 
1578
+ private async teardownHotspotTransport(): Promise<void> {
1579
+ if (this.selectedTransport !== "hotspot" && !this.hotspotManifestUrl) return
1580
+ await hotspotOtaTransport.teardown()
1581
+ this.hotspotManifestUrl = null
1582
+ this.hotspotPhase = "idle"
1583
+ this.hotspotArtifactPercent = null
1584
+ this.emitInternalChange()
1585
+ }
1586
+
1062
1587
  /**
1063
1588
  * After sending ota_query_status, wait QUERY_REPLY_TIMEOUT_MS for the glasses
1064
- * to reply with a useful ota_status. If nothing arrives (e.g. the glasses'
1065
- * OTA session was wiped between mount and reconnect), or the glasses reply
1066
- * with an explicit "idle" status (no session), fall back to ota_start so the
1067
- * user doesn't sit on a spinner forever.
1589
+ * to reply with a useful ota_status. A unified session may start again only
1590
+ * after an explicit "idle" reply, or after a locally rejected ota_start has
1591
+ * produced no activity and no authoritative reply across reconnect. Legacy
1592
+ * builds ignore ota_query_status, so their established timeout fallback
1593
+ * remains the compatibility path.
1068
1594
  *
1069
1595
  * Cleared as soon as a non-idle otaStatus or any otaProgress lands in the
1070
1596
  * store (see the reaction block above). An idle reply intentionally does NOT
1071
1597
  * cancel the fallback, so reconnects against a wiped/lost session still recover.
1072
1598
  */
1073
- private armQueryReplyFallback(reason: "reconnect" | "initial-mount"): void {
1599
+ private armQueryReplyFallback(reason: "reconnect" | "initial-mount" | "retry"): void {
1074
1600
  this.clearQueryReplyTimeout()
1075
1601
  this.queryReplyTimeout = setTimeout(() => {
1076
1602
  this.queryReplyTimeout = null
@@ -1081,21 +1607,24 @@ class OtaInstallCoordinator {
1081
1607
  // whatever legacy-shaped status/progress sits in the store is a pre-query
1082
1608
  // leftover, not a reply — a NEW legacy event would have cleared this timer
1083
1609
  // through the reaction pass. Only a unified reply suppresses the fallback.
1084
- const legacySession = isLegacyShapedOtaSession(
1085
- state.otaStatus,
1086
- state.otaProgress,
1087
- this.initialBuildNumber || state.buildNumber,
1088
- )
1610
+ const legacySession = this.isLegacySessionShapeNow()
1089
1611
  if (!legacySession && hasRecoveringOtaReply(state.otaStatus, state.otaProgress)) return
1612
+ const explicitIdle = state.otaStatus?.status === "idle"
1613
+ const rejectedStart = this.otaStartOwnership?.outcome === "rejected"
1614
+ if (!legacySession && !explicitIdle && !rejectedStart) {
1615
+ console.log(
1616
+ `[OTA_PROGRESS] watchdog: ota_query_status got no authoritative idle reply in ${QUERY_REPLY_TIMEOUT_MS}ms (reason=${reason}); preserving the existing session`,
1617
+ )
1618
+ return
1619
+ }
1090
1620
  // Don't fire if we've left the active phase (e.g. user backed out, error overlay).
1091
1621
  if (isTerminalForWatchdog(this.computeDisplayStateNow())) return
1092
- console.log(
1093
- `[OTA_PROGRESS] watchdog: ota_query_status got no useful reply in ${QUERY_REPLY_TIMEOUT_MS}ms (reason=${reason}), falling back to ota_start`,
1094
- )
1622
+ let fallbackCause = "legacy query timeout"
1623
+ if (explicitIdle) fallbackCause = "idle ota_status"
1624
+ else if (rejectedStart) fallbackCause = "rejected ota_start with no recovery reply"
1625
+ console.log(`[OTA_PROGRESS] watchdog: ${fallbackCause} (reason=${reason}), falling back to ota_start`)
1095
1626
  this.retryCount = 0
1096
- this.hasFirstActivity = false
1097
- this.hasFirstNonZeroProgress = false
1098
- this.hasReceivedAck = false
1627
+ if (this.otaStartOwnership?.outcome !== "pending" && !this.prepareFreshOtaStartAttempt()) return
1099
1628
  void this.sendOtaStartWithWatchdogs()
1100
1629
  }, QUERY_REPLY_TIMEOUT_MS)
1101
1630
  }
@@ -1131,13 +1660,6 @@ class OtaInstallCoordinator {
1131
1660
  this.globalTimeoutStarted = false
1132
1661
  }
1133
1662
 
1134
- private clearPostApkDelay(): void {
1135
- if (this.postApkDelay) {
1136
- clearTimeout(this.postApkDelay)
1137
- this.postApkDelay = null
1138
- }
1139
- }
1140
-
1141
1663
  private clearPingInterval(): void {
1142
1664
  if (this.pingInterval) {
1143
1665
  clearInterval(this.pingInterval)
@@ -1159,6 +1681,13 @@ class OtaInstallCoordinator {
1159
1681
  }
1160
1682
  }
1161
1683
 
1684
+ private clearRestartRecoveryTimeout(): void {
1685
+ if (this.restartRecoveryTimeout) {
1686
+ clearTimeout(this.restartRecoveryTimeout)
1687
+ this.restartRecoveryTimeout = null
1688
+ }
1689
+ }
1690
+
1162
1691
  private clearLegacyApkSettleTimer(): void {
1163
1692
  if (this.legacyApkSettleTimer) {
1164
1693
  clearTimeout(this.legacyApkSettleTimer)
@@ -1188,7 +1717,6 @@ class OtaInstallCoordinator {
1188
1717
  this.clearRetryTimeout()
1189
1718
  this.clearStuckTimeout()
1190
1719
  this.clearProgressTimeout()
1191
- this.clearPostApkDelay()
1192
1720
  this.clearQueryReplyTimeout()
1193
1721
  }
1194
1722
 
@@ -1198,6 +1726,7 @@ class OtaInstallCoordinator {
1198
1726
  this.clearPingInterval()
1199
1727
  this.clearLegacyApkSettleTimer()
1200
1728
  this.clearMtkSimulationTimers()
1729
+ this.clearRestartRecoveryTimeout()
1201
1730
  }
1202
1731
  }
1203
1732