@mentra/bluetooth-sdk 3.1.0-dev.7 → 3.1.0-dev.70

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 (51) hide show
  1. package/README.md +40 -34
  2. package/android/src/main/java/com/mentra/bluetoothsdk/Bridge.kt +0 -5
  3. package/android/src/main/java/com/mentra/bluetoothsdk/DeviceManager.kt +13 -8
  4. package/android/src/main/java/com/mentra/bluetoothsdk/GeneratedChangelogCatalog.kt +7 -0
  5. package/android/src/main/java/com/mentra/bluetoothsdk/GeneratedReleaseMetadata.kt +5 -5
  6. package/android/src/main/java/com/mentra/bluetoothsdk/MentraBluetoothSdk.kt +15 -0
  7. package/android/src/main/java/com/mentra/bluetoothsdk/ReleaseChangelog.kt +61 -0
  8. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraLive.kt +81 -69
  9. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraLivePairingAdvertisement.kt +54 -0
  10. package/android/src/test/java/com/mentra/bluetoothsdk/ReleaseChangelogTest.kt +13 -0
  11. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/MentraLivePairingAdvertisementParserTest.kt +133 -0
  12. package/build/BluetoothSdk.types.d.ts +26 -5
  13. package/build/BluetoothSdk.types.d.ts.map +1 -1
  14. package/build/BluetoothSdk.types.js.map +1 -1
  15. package/build/changelogs.d.ts +7 -0
  16. package/build/changelogs.d.ts.map +1 -0
  17. package/build/changelogs.js +44 -0
  18. package/build/changelogs.js.map +1 -0
  19. package/build/generated/changelogCatalog.d.ts +6 -0
  20. package/build/generated/changelogCatalog.d.ts.map +1 -0
  21. package/build/generated/changelogCatalog.js +8 -0
  22. package/build/generated/changelogCatalog.js.map +1 -0
  23. package/build/generated/releaseMetadata.js +5 -5
  24. package/build/generated/releaseMetadata.js.map +1 -1
  25. package/build/index.d.ts +1 -1
  26. package/build/index.d.ts.map +1 -1
  27. package/build/index.js +14 -0
  28. package/build/index.js.map +1 -1
  29. package/build/ota-transport/index.d.ts +67 -0
  30. package/build/ota-transport/index.d.ts.map +1 -0
  31. package/build/ota-transport/index.js +58 -0
  32. package/build/ota-transport/index.js.map +1 -0
  33. package/ios/Source/BluetoothSdkDefaults.swift +1 -1
  34. package/ios/Source/Bridge.swift +1 -6
  35. package/ios/Source/DeviceManager.swift +13 -8
  36. package/ios/Source/GeneratedChangelogCatalog.swift +6 -0
  37. package/ios/Source/GeneratedReleaseMetadata.swift +5 -5
  38. package/ios/Source/MentraBluetoothSDK.swift +17 -0
  39. package/ios/Source/ReleaseChangelog.swift +65 -0
  40. package/ios/Source/sgcs/MentraLive.swift +93 -80
  41. package/ios/Source/sgcs/MentraLivePairingAdvertisement.swift +64 -0
  42. package/ios/Tests/MentraLivePairingAdvertisementTests.swift +160 -0
  43. package/ios/Tests/ReleaseChangelogTests.swift +13 -0
  44. package/package.json +6 -1
  45. package/scripts/public-ota-api.test.mjs +37 -0
  46. package/src/BluetoothSdk.types.ts +28 -5
  47. package/src/changelogs.ts +44 -0
  48. package/src/generated/changelogCatalog.ts +9 -0
  49. package/src/generated/releaseMetadata.ts +5 -5
  50. package/src/index.ts +24 -0
  51. package/src/ota-transport/index.ts +116 -0
package/README.md CHANGED
@@ -349,7 +349,14 @@ Android and iOS async APIs use `BluetoothSdkException` / `BluetoothSdkError` for
349
349
 
350
350
  ## OTA Updates
351
351
 
352
- Mentra Live firmware owns the OTA flow. The SDK mirrors the MentraOS app commands and events:
352
+ React Native apps should use `MentraLiveOtaFlow` or `useMentraLiveOta` from
353
+ `@mentra/engine/ota`. They provide the same tested Wi-Fi/hotspot,
354
+ APK/MTK/BES, restart, retry, and verification flow as the Mentra App. See
355
+ [Update Mentra Live](https://docs.mentraglass.com/mentra-live/software-update).
356
+
357
+ The Bluetooth SDK exposes the lower-level commands and transport capabilities
358
+ that Mentra Engine and native apps build on. They are not a replacement for the
359
+ Engine coordinator:
353
360
 
354
361
  - `setOtaVersionUrl(url)` selects the manifest used by subsequent checks and installs. Use this
355
362
  for a customer-controlled internal update server.
@@ -357,39 +364,34 @@ Mentra Live firmware owns the OTA flow. The SDK mirrors the MentraOS app command
357
364
  when no override was supplied.
358
365
  - `checkForOtaUpdate()` fetches the configured manifest and resolves with `true` when an ASG APK, MTK, or BES update is available.
359
366
  - `startOtaUpdate()` sends `ota_start` with the same configured manifest URL and resolves with the ASG start ack after your app presents the update and the user accepts it.
360
-
361
- The default manifest is derived from the SDK version:
362
- `https://github.com/Mentra-Community/MentraOS/releases/download/bluetooth-sdk-ota/bluetooth-sdk-<sdkVersion>-version.json`.
363
- Each published SDK version points at a durable ASG client APK and firmware
364
- manifest that were built for that SDK release. Pre-wall-clock ASG builds that
365
- ignore `ota_start.ota_version_url` are checked against the URL they advertise,
366
- or the production default if they do not advertise one, so the app does not
367
- prompt for an update the glasses cannot install.
368
-
369
- ```ts
370
- import BluetoothSdk from '@mentra/bluetooth-sdk'
371
-
372
- // Optional: point both the phone-side check and glasses install at an internal server.
373
- BluetoothSdk.setOtaVersionUrl('https://updates.example.internal/mentra-live/version.json')
374
-
375
- BluetoothSdk.addListener('ota_status', (event) => {
376
- console.log(`OTA ${event.status}: ${event.overall_percent}%`)
377
- })
378
-
379
- const hasUpdate = await BluetoothSdk.checkForOtaUpdate()
380
- if (hasUpdate) {
381
- const userAccepted = await promptUserToInstallUpdate() // your app's UI
382
- if (userAccepted) {
383
- const startAck = await BluetoothSdk.startOtaUpdate()
384
- console.log('OTA start acknowledged', startAck.timestamp)
385
- }
386
- }
387
- ```
388
-
389
- Bluetooth SDK releases also publish
390
- `bluetooth-sdk-<sdkVersion>-ota-bundle.zip` beside the default manifest. The archive contains
391
- `version.template.json`, every referenced ASG/MTK/BES artifact, `SHA256SUMS`, and a dependency-free
392
- configuration script. After unpacking it, generate the final manifest for its exact hosting URL:
367
+ - `queryOtaStatus()` returns the correlated status for the active session.
368
+ - `@mentra/bluetooth-sdk/ota-transport` exposes scoped hotspot networking and
369
+ the local OTA artifact server without exposing native module internals.
370
+
371
+ Release CI embeds an immutable manifest URL and checksum into every published
372
+ SDK distribution. The completed coordinated release record correlates the SDK
373
+ package with its exact manifest and OTA bundle. A completed release is available at
374
+ `https://github.com/Mentra-Community/MentraOS/releases/tag/mentra-v<releaseIdentity>`
375
+ and contains `mentra-release-<releaseIdentity>.json`, which records the exact
376
+ package coordinates, asset URLs, sizes, checksums, and build provenance.
377
+
378
+ Pre-wall-clock ASG builds that ignore `ota_start.ota_version_url` are checked
379
+ against the URL they advertise, or the production default if they do not
380
+ advertise one, so the app does not prompt for an update the glasses cannot
381
+ install.
382
+
383
+ Use these primitives directly only when implementing the documented native
384
+ Android/iOS OTA contract or infrastructure beneath a coordinator. React Native
385
+ application pages should render Mentra Engine's semantic controller state
386
+ instead of sequencing raw events.
387
+
388
+ Each coordinated prerelease publishes a portable OTA bundle. Stable releases
389
+ promote the exact beta-tested OTA bytes, so always resolve the bundle coordinate
390
+ and URL from `mentra-release-<releaseIdentity>.json` rather than constructing a
391
+ filename. The selected archive contains
392
+ `version.template.json`, every referenced ASG/MTK/BES artifact, `SHA256SUMS`,
393
+ and a dependency-free configuration script. After unpacking it, generate the
394
+ final manifest for its exact hosting URL:
393
395
 
394
396
  ```sh
395
397
  node configure.mjs https://updates.example.internal/mentra-live/version.json
@@ -405,6 +407,10 @@ automatically.
405
407
 
406
408
  OTA requires Mentra Live glasses firmware that supports the ASG OTA protocol and network access from the glasses. During install, normal BLE traffic can be interrupted and the glasses may restart; keep the app connected and avoid sending unrelated commands until `ota_status.status` is `complete` or `failed`.
407
409
 
410
+ For release selection, bundle verification, analytics opt-out, internal server
411
+ requirements, and disconnected validation, see the
412
+ [air-gapped deployment guide](https://docs.mentraglass.com/mentra-live/air-gapped-deployment).
413
+
408
414
  Mentra Live also rejects `ota_start` before acknowledgement when its known battery level is below 5%, emitting a failed `ota_status` with `error_message: "battery_low"`. Unknown battery state remains fail-open, so apps should keep their own (typically stricter) user-facing battery policy.
409
415
 
410
416
  ## Photo Upload
@@ -148,21 +148,17 @@ public class Bridge private constructor() {
148
148
  @JvmStatic
149
149
  fun sendPairingInfo(
150
150
  hadPreviousBond: Boolean,
151
- transferId: String? = null,
152
151
  pairingCode: String? = null,
153
152
  classicBondReady: Boolean = false,
154
153
  securePairingCapable: Boolean = true,
155
154
  protocolVersion: Int = 1,
156
- binding: String? = null,
157
155
  ) {
158
156
  val data = HashMap<String, Any>()
159
157
  data["had_previous_bond"] = hadPreviousBond
160
- if (transferId != null) data["transfer_id"] = transferId
161
158
  if (pairingCode != null) data["pairing_code"] = pairingCode
162
159
  data["classic_bond_ready"] = classicBondReady
163
160
  data["secure_pairing_capable"] = securePairingCapable
164
161
  data["protocol_version"] = protocolVersion
165
- if (binding != null) data["binding"] = binding
166
162
  sendTypedMessage("pairing_info", data as Map<String, Any>)
167
163
  }
168
164
 
@@ -912,4 +908,3 @@ public class Bridge private constructor() {
912
908
  }
913
909
  }
914
910
  }
915
-
@@ -1359,9 +1359,7 @@ class DeviceManager {
1359
1359
  if (pendingDeviceAddress.isNotEmpty()) {
1360
1360
  deviceAddress = pendingDeviceAddress
1361
1361
  }
1362
- pendingDeviceName = ""
1363
- pendingDeviceAddress = ""
1364
- pendingWearable = ""
1362
+ clearPendingConnection()
1365
1363
 
1366
1364
  val readyKey = "${sgc?.type}:${deviceName}"
1367
1365
  val now = System.currentTimeMillis()
@@ -2137,7 +2135,7 @@ class DeviceManager {
2137
2135
  return
2138
2136
  }
2139
2137
 
2140
- disconnect()
2138
+ disconnect(clearPendingIdentity = false)
2141
2139
  Thread.sleep(100)
2142
2140
  searching = true
2143
2141
  pendingDeviceName = name
@@ -2170,7 +2168,7 @@ class DeviceManager {
2170
2168
  handleDeviceReady()
2171
2169
  }
2172
2170
 
2173
- fun disconnect() {
2171
+ fun disconnect(clearPendingIdentity: Boolean = true) {
2174
2172
  sgc?.clearDisplay()
2175
2173
  sgc?.disconnect()
2176
2174
  sgc = null // Clear the SGC reference after disconnect
@@ -2202,6 +2200,15 @@ class DeviceManager {
2202
2200
  DeviceStore.apply("glasses", "controllerConnected", false)
2203
2201
  controller?.disconnect()
2204
2202
  controller = null
2203
+ if (clearPendingIdentity) {
2204
+ clearPendingConnection()
2205
+ }
2206
+ }
2207
+
2208
+ private fun clearPendingConnection() {
2209
+ pendingDeviceName = ""
2210
+ pendingDeviceAddress = ""
2211
+ pendingWearable = ""
2205
2212
  }
2206
2213
 
2207
2214
  fun disconnectController() {
@@ -2276,9 +2283,7 @@ class DeviceManager {
2276
2283
  defaultWearable = ""
2277
2284
  deviceName = ""
2278
2285
  deviceAddress = ""
2279
- pendingDeviceName = ""
2280
- pendingDeviceAddress = ""
2281
- pendingWearable = ""
2286
+ clearPendingConnection()
2282
2287
  Bridge.saveSetting("default_wearable", "")
2283
2288
  Bridge.saveSetting("device_name", "")
2284
2289
  Bridge.saveSetting("device_address", "")
@@ -0,0 +1,7 @@
1
+ package com.mentra.bluetoothsdk
2
+
3
+ /** Generated from /changelogs. Do not edit directly. */
4
+ internal val GENERATED_RELEASE_CHANGELOGS: List<ReleaseChangelog> =
5
+ listOf(
6
+ ReleaseChangelog(version = "3.1.0", markdown = "Software updates are more reliable, with clearer progress and recovery when the glasses restart.\n\n- MentraOS, Mentra Engine, the Bluetooth SDK, and the glasses client now share one coordinated release version.\n- Mentra Live updates can continue across APK, system, and firmware restarts without asking the user to start the same update again.\n- Bluetooth photo capture and transfer diagnostics are more precise and less disruptive to normal glasses traffic."),
7
+ )
@@ -3,9 +3,9 @@ package com.mentra.bluetoothsdk
3
3
  /** Generated by release CI. Do not edit in a release checkout. */
4
4
  internal object GeneratedReleaseMetadata {
5
5
  const val FAMILY_BASE_VERSION: String = "3.1.0"
6
- const val RELEASE_IDENTITY: String = "3.1.0-dev.7"
7
- const val RELEASE_SET_ID: String = "mentra-3.1.0-dev.7"
8
- const val SOURCE_COMMIT: String = "872697a6395c31d30ec75d64f039eaa763dcbb51"
9
- const val OTA_MANIFEST_URL: String = "https://github.com/Mentra-Community/MentraOS/releases/download/mentra-v3.1.0-dev.7/mentra-live-ota-3.1.0-dev.7.json"
10
- const val OTA_MANIFEST_SHA256: String = "8fbea7589345658dc5dffcb394229ffc5c52fbe75352acb8f6827d83290ce750"
6
+ const val RELEASE_IDENTITY: String = "3.1.0-dev.70"
7
+ const val RELEASE_SET_ID: String = "mentra-3.1.0-dev.70"
8
+ const val SOURCE_COMMIT: String = "1554856d69f4cd5d14c1ea769171a444d3e0c4b4"
9
+ const val OTA_MANIFEST_URL: String = "https://github.com/Mentra-Community/MentraOS/releases/download/mentra-builds-v3.1.0/mentra-live-ota-3.1.0-dev.70.json"
10
+ const val OTA_MANIFEST_SHA256: String = "593990d9b25a7a47dd80c6a7e3486964b4349191a7abe885fcc24135e1604c69"
11
11
  }
@@ -284,6 +284,7 @@ class MentraBluetoothSdk private constructor(
284
284
  DeviceStore.apply(ObservableStore.BLUETOOTH_CATEGORY, "project_name", device.projectName ?: "")
285
285
  DeviceStore.apply(ObservableStore.BLUETOOTH_CATEGORY, "pending_device_name", "")
286
286
  DeviceStore.apply(ObservableStore.BLUETOOTH_CATEGORY, "pending_device_address", "")
287
+ DeviceStore.apply(ObservableStore.BLUETOOTH_CATEGORY, "pending_device_secure_pairing_capable", "")
287
288
  } finally {
288
289
  suppressDefaultDeviceEvents = false
289
290
  }
@@ -299,6 +300,7 @@ class MentraBluetoothSdk private constructor(
299
300
  DeviceStore.apply(ObservableStore.BLUETOOTH_CATEGORY, "project_name", "")
300
301
  DeviceStore.apply(ObservableStore.BLUETOOTH_CATEGORY, "pending_device_name", "")
301
302
  DeviceStore.apply(ObservableStore.BLUETOOTH_CATEGORY, "pending_device_address", "")
303
+ DeviceStore.apply(ObservableStore.BLUETOOTH_CATEGORY, "pending_device_secure_pairing_capable", "")
302
304
  } finally {
303
305
  suppressDefaultDeviceEvents = false
304
306
  }
@@ -428,6 +430,11 @@ class MentraBluetoothSdk private constructor(
428
430
  "pending_device_address",
429
431
  device.address ?: "",
430
432
  )
433
+ DeviceStore.apply(
434
+ ObservableStore.BLUETOOTH_CATEGORY,
435
+ "pending_device_secure_pairing_capable",
436
+ device.securePairingCapable ?: "",
437
+ )
431
438
  }
432
439
  }
433
440
  DeviceStore.apply(ObservableStore.BLUETOOTH_CATEGORY, "pending_wearable", device.model.deviceType)
@@ -454,6 +461,7 @@ class MentraBluetoothSdk private constructor(
454
461
  fun cancelConnectionAttempt() {
455
462
  DeviceStore.apply(ObservableStore.BLUETOOTH_CATEGORY, "pending_device_name", "")
456
463
  DeviceStore.apply(ObservableStore.BLUETOOTH_CATEGORY, "pending_device_address", "")
464
+ DeviceStore.apply(ObservableStore.BLUETOOTH_CATEGORY, "pending_device_secure_pairing_capable", "")
457
465
  deviceManager.disconnect()
458
466
  }
459
467
 
@@ -464,6 +472,7 @@ class MentraBluetoothSdk private constructor(
464
472
  fun disconnect() {
465
473
  DeviceStore.apply(ObservableStore.BLUETOOTH_CATEGORY, "pending_device_name", "")
466
474
  DeviceStore.apply(ObservableStore.BLUETOOTH_CATEGORY, "pending_device_address", "")
475
+ DeviceStore.apply(ObservableStore.BLUETOOTH_CATEGORY, "pending_device_secure_pairing_capable", "")
467
476
  deviceManager.disconnect()
468
477
  }
469
478
 
@@ -1216,6 +1225,12 @@ class MentraBluetoothSdk private constructor(
1216
1225
  )
1217
1226
  }
1218
1227
 
1228
+ /** Return bundled release changelogs crossed between two coordinated product versions, newest first. */
1229
+ fun getReleaseChangelogs(
1230
+ fromVersion: String? = null,
1231
+ toVersion: String? = null,
1232
+ ): List<ReleaseChangelog> = ReleaseChangelogCatalog.select(fromVersion, toVersion)
1233
+
1219
1234
  /** Ask connected Mentra Live glasses to report the current OTA install/session status. */
1220
1235
  private suspend fun queryOtaStatus(): OtaQueryResult =
1221
1236
  performOtaQuery("OTA status query") {
@@ -0,0 +1,61 @@
1
+ package com.mentra.bluetoothsdk
2
+
3
+ /** One coordinated Mentra release note authored in /changelogs/<version>.md. */
4
+ data class ReleaseChangelog(
5
+ val version: String,
6
+ val markdown: String,
7
+ )
8
+
9
+ internal object ReleaseChangelogCatalog {
10
+ private val baseVersionPattern = Regex("^(0|[1-9]\\d*)\\.(0|[1-9]\\d*)\\.(0|[1-9]\\d*)(?:[-+].*)?$")
11
+
12
+ fun select(
13
+ fromVersion: String?,
14
+ toVersion: String?,
15
+ ): List<ReleaseChangelog> {
16
+ val fallbackTarget =
17
+ GeneratedReleaseMetadata.FAMILY_BASE_VERSION.ifBlank {
18
+ GENERATED_RELEASE_CHANGELOGS.firstOrNull()?.version.orEmpty()
19
+ }
20
+ if (fallbackTarget.isBlank() && toVersion == null) return emptyList()
21
+ val target = baseVersion(toVersion ?: fallbackTarget, "toVersion")
22
+ if (GENERATED_RELEASE_CHANGELOGS.none { it.version == target }) {
23
+ throw BluetoothSdkException("missing_changelog", "No changelog is bundled for target version $target.")
24
+ }
25
+ if (fromVersion == null) return GENERATED_RELEASE_CHANGELOGS.filter { it.version == target }
26
+
27
+ val source = baseVersion(fromVersion, "fromVersion")
28
+ val direction = compareVersions(target, source)
29
+ return GENERATED_RELEASE_CHANGELOGS.filter { entry ->
30
+ when {
31
+ direction == 0 -> entry.version == target
32
+ direction > 0 -> compareVersions(entry.version, source) > 0 && compareVersions(entry.version, target) <= 0
33
+ else -> compareVersions(entry.version, source) < 0 && compareVersions(entry.version, target) >= 0
34
+ }
35
+ }
36
+ }
37
+
38
+ private fun baseVersion(
39
+ version: String,
40
+ label: String,
41
+ ): String {
42
+ val match = baseVersionPattern.matchEntire(version.trim())
43
+ ?: throw BluetoothSdkException(
44
+ "invalid_changelog_version",
45
+ "$label must be a semantic version such as 3.1.0, 3.1.0-dev.4, or 3.1.0-beta.2.",
46
+ )
47
+ return "${match.groupValues[1]}.${match.groupValues[2]}.${match.groupValues[3]}"
48
+ }
49
+
50
+ private fun compareVersions(
51
+ left: String,
52
+ right: String,
53
+ ): Int {
54
+ val a = left.split(".").map(String::toInt)
55
+ val b = right.split(".").map(String::toInt)
56
+ for (index in 0..2) {
57
+ if (a[index] != b[index]) return a[index] - b[index]
58
+ }
59
+ return 0
60
+ }
61
+ }
@@ -82,6 +82,33 @@ internal fun hasRequiredMentraLiveCoreCharacteristics(
82
82
  hasTxCharacteristic: Boolean,
83
83
  ): Boolean = hasRxCharacteristic && hasTxCharacteristic
84
84
 
85
+ internal fun isPendingMentraLivePairingTarget(
86
+ connectedName: String,
87
+ connectedAddress: String,
88
+ pendingName: String,
89
+ pendingAddress: String,
90
+ ): Boolean {
91
+ if (pendingAddress.isNotEmpty()) {
92
+ return connectedAddress.equals(pendingAddress, ignoreCase = true)
93
+ }
94
+ return pendingName.isNotEmpty() && connectedName == pendingName
95
+ }
96
+
97
+ internal fun shouldRecoverConnectedMentraLivePairingTarget(
98
+ isConnected: Boolean,
99
+ connectedName: String,
100
+ connectedAddress: String,
101
+ pendingName: String,
102
+ pendingAddress: String,
103
+ ): Boolean =
104
+ isConnected &&
105
+ isPendingMentraLivePairingTarget(
106
+ connectedName,
107
+ connectedAddress,
108
+ pendingName,
109
+ pendingAddress,
110
+ )
111
+
85
112
  /**
86
113
  * Smart Glasses Communicator for Mentra Live (K900) glasses Uses BLE to communicate with the
87
114
  * glasses
@@ -159,59 +186,25 @@ class MentraLive : SGCManager() {
159
186
  10_000L // 10s before first scan after shutdown
160
187
 
161
188
  private const val MENTRA_MANUFACTURER_ID = 0xB822
162
- private const val ADV_MANUF_PAIRING_FLAG_OFFSET = 5
163
- private const val ADV_PAIRING_DISCOVERABLE: Byte = 0x01
164
- private const val ADV_PAIRING_PROTOCOL_VERSION_MIN = 1
165
- private const val ADV_PAIRING_PROTOCOL_VERSION_MAX = 15
166
189
 
167
190
  private fun manufacturerData(result: ScanResult): ByteArray? {
168
191
  return result.scanRecord?.getManufacturerSpecificData(MENTRA_MANUFACTURER_ID)
169
192
  }
170
193
 
171
- /**
172
- * OS-1615 ads append `flag | version | capability | code_lo | code_hi` after the
173
- * connected byte. Field firmware uses the same 0xB822 company id but then writes the
174
- * XOR'd Classic MAC at those offsets. Length alone is not a pairing flag: treating MAC
175
- * bytes as version/capability hides every old unit as "secure, not in pairing mode".
176
- */
177
- private fun hasSecurePairingTrailer(manufData: ByteArray?): Boolean {
178
- if (manufData == null) {
179
- return false
180
- }
181
- val trailerBase = ADV_MANUF_PAIRING_FLAG_OFFSET + 1
182
- if (manufData.size < trailerBase + 4) {
183
- return false
184
- }
185
- val version = manufData[trailerBase].toInt() and 0xFF
186
- val capability = manufData[trailerBase + 1].toInt() and 0xFF
187
- return version in ADV_PAIRING_PROTOCOL_VERSION_MIN..ADV_PAIRING_PROTOCOL_VERSION_MAX &&
188
- (capability and 0x01) != 0
189
- }
190
-
191
194
  private fun isPairingDiscoverable(result: ScanResult): Boolean {
192
- val manufData = manufacturerData(result) ?: return false
193
- if (!hasSecurePairingTrailer(manufData)) {
194
- return false
195
- }
196
- return manufData[ADV_MANUF_PAIRING_FLAG_OFFSET] == ADV_PAIRING_DISCOVERABLE
195
+ return MentraLivePairingAdvertisementParser.parse(manufacturerData(result))?.pairingMode ==
196
+ true
197
197
  }
198
198
 
199
199
  private fun advertisesPairingFlag(result: ScanResult): Boolean {
200
- return hasSecurePairingTrailer(manufacturerData(result))
200
+ return MentraLivePairingAdvertisementParser.parse(manufacturerData(result)) != null
201
201
  }
202
202
 
203
- /** Trailer immediately after pairing flag: version | capability | code_lo | code_hi */
204
203
  private fun parseSecurePairingTrailer(result: ScanResult): Triple<Boolean, String?, Boolean> {
205
- val manufData = manufacturerData(result) ?: return Triple(false, null, false)
206
- if (!hasSecurePairingTrailer(manufData)) {
207
- return Triple(false, null, false)
208
- }
209
- val pairingMode = manufData[ADV_MANUF_PAIRING_FLAG_OFFSET] == ADV_PAIRING_DISCOVERABLE
210
- val trailerBase = ADV_MANUF_PAIRING_FLAG_OFFSET + 1
211
- val codeLo = manufData[trailerBase + 2].toInt() and 0xFF
212
- val codeHi = manufData[trailerBase + 3].toInt() and 0xFF
213
- val code = String.format("%02X%02X", codeHi, codeLo)
214
- return Triple(pairingMode, code, true)
204
+ val advertisement =
205
+ MentraLivePairingAdvertisementParser.parse(manufacturerData(result))
206
+ ?: return Triple(false, null, false)
207
+ return Triple(advertisement.pairingMode, advertisement.pairingCode, true)
215
208
  }
216
209
 
217
210
  fun isMentraLiveBluetoothName(name: String?): Boolean {
@@ -1299,11 +1292,6 @@ class MentraLive : SGCManager() {
1299
1292
  val generation = ++scanGeneration
1300
1293
  isScanning = true
1301
1294
  bluetoothScanner!!.startScan(filters, settings, scanCallback)
1302
- // Connected glasses stop advertising (BLE_CONNECTION_MAX=1). Pairing scan
1303
- // would otherwise return empty even though GATT is already up.
1304
- if (!isReconnecting) {
1305
- handler.post { emitConnectedDeviceForPairingScan() }
1306
- }
1307
1295
 
1308
1296
  // Set a timeout to stop scanning
1309
1297
  handler.postDelayed(
@@ -1386,29 +1374,44 @@ class MentraLive : SGCManager() {
1386
1374
  }
1387
1375
 
1388
1376
  /**
1389
- * Pairing scan listens for advertisements. A unit that is already GATT-connected
1390
- * (typical after a CTKD retry / back-out on field firmware) has stopped ADV, so
1391
- * emit it as pairable or the scan list stays empty.
1377
+ * A selected pairing target can stop advertising once its GATT link comes up, before
1378
+ * readiness promotes it to the default device. Re-emit only that explicit pending target;
1379
+ * an established owner's connected glasses have no pending name/address and stay hidden.
1392
1380
  */
1393
- private fun emitConnectedDeviceForPairingScan() {
1394
- if (!isConnected || isReconnecting || pairingYieldActive) {
1395
- return
1396
- }
1381
+ private fun emitConnectedPendingDeviceForPairingScan() {
1382
+ if (!manualDiscoveryActive || isReconnecting || pairingYieldActive) return
1397
1383
  val device = connectedDevice ?: bluetoothGatt?.device ?: return
1398
1384
  val name = safeBondedDeviceName(device)
1399
- if (!isMentraLiveBluetoothName(name)) {
1385
+ if (!isMentraLiveBluetoothName(name)) return
1386
+
1387
+ val pendingName = DeviceStore.get("bluetooth", "pending_device_name") as? String ?: ""
1388
+ val pendingAddress =
1389
+ DeviceStore.get("bluetooth", "pending_device_address") as? String ?: ""
1390
+ val pendingSecurePairingCapable =
1391
+ DeviceStore.get("bluetooth", "pending_device_secure_pairing_capable") as? Boolean
1392
+ val address = device.address ?: ""
1393
+ if (
1394
+ !shouldRecoverConnectedMentraLivePairingTarget(
1395
+ isConnected,
1396
+ name,
1397
+ address,
1398
+ pendingName,
1399
+ pendingAddress,
1400
+ )
1401
+ ) {
1400
1402
  return
1401
1403
  }
1404
+
1402
1405
  Bridge.log(
1403
- "LIVE: Pairing scan: already GATT-connected to $name — emitting as pairable (ADV off while connected)"
1406
+ "LIVE: Pairing scan: recovering connected pending target $name ($address)"
1404
1407
  )
1405
1408
  Bridge.sendDiscoveredDevice(
1406
1409
  DeviceTypes.LIVE,
1407
1410
  name,
1408
- device.address ?: "",
1411
+ address,
1409
1412
  pairingMode = true,
1410
1413
  pairingCode = null,
1411
- securePairingCapable = false,
1414
+ securePairingCapable = pendingSecurePairingCapable,
1412
1415
  )
1413
1416
  }
1414
1417
 
@@ -1475,10 +1478,11 @@ class MentraLive : SGCManager() {
1475
1478
  advertisesPairingFlag(result) &&
1476
1479
  !isPairingDiscoverable(result)
1477
1480
  ) {
1478
- // Tell RN a nearby unit is advertising but not pairable so the
1479
- // empty-state hint can fire. The scan list never shows these.
1481
+ // Tell RN a nearby unit is advertising but not pairable. The scan
1482
+ // screen keeps it visible with pairing-mode guidance but blocks the
1483
+ // connection until a pairable advertisement arrives.
1480
1484
  Bridge.log(
1481
- "LIVE: Nearby $deviceName is secure firmware not in pairing mode — hiding from scan list"
1485
+ "LIVE: Nearby $deviceName is secure firmware not in pairing mode — exposing as non-pairable"
1482
1486
  )
1483
1487
  Bridge.sendDiscoveredDevice(
1484
1488
  DeviceTypes.LIVE,
@@ -2109,6 +2113,7 @@ class MentraLive : SGCManager() {
2109
2113
  isConnecting = false
2110
2114
  isConnected = true
2111
2115
  connectedDevice = gatt.device
2116
+ emitConnectedPendingDeviceForPairingScan()
2112
2117
 
2113
2118
  DeviceStore.apply("glasses", "bluetoothName", connectedDevice!!.name)
2114
2119
  // Persist MAC so reconnection can use direct GATT instead of scanning
@@ -3817,19 +3822,16 @@ class MentraLive : SGCManager() {
3817
3822
  val body = HashMap<String, Any>()
3818
3823
  body["window_ms"] = windowMs
3819
3824
  body["reason"] = json.optString("reason", "user_gesture")
3820
- if (json.has("txn")) body["txn"] = json.optInt("txn")
3821
3825
  Bridge.sendTypedMessage("entering_pairing_mode", body)
3822
3826
  }
3823
3827
  "pairing_info" ->
3824
3828
  Bridge.sendPairingInfo(
3825
3829
  json.optBoolean("had_previous_bond", false),
3826
- jsonNullableString(json, "transfer_id"),
3827
3830
  jsonNullableString(json, "pairing_code"),
3828
3831
  json.optBoolean("classic_bond_ready", false),
3829
3832
  // Legacy firmware that omits this field is not secure-capable.
3830
3833
  json.optBoolean("secure_pairing_capable", false),
3831
3834
  json.optInt("protocol_version", 1),
3832
- jsonNullableString(json, "binding"),
3833
3835
  )
3834
3836
  "imu_response",
3835
3837
  "imu_stream_response",
@@ -5956,6 +5958,7 @@ class MentraLive : SGCManager() {
5956
5958
 
5957
5959
  // Start scanning for BLE devices
5958
5960
  startScan()
5961
+ handler.post { emitConnectedPendingDeviceForPairingScan() }
5959
5962
  }
5960
5963
 
5961
5964
  private fun releaseStaleClassicForDiscovery() {
@@ -6852,12 +6855,21 @@ class MentraLive : SGCManager() {
6852
6855
  ")"
6853
6856
  )
6854
6857
  markPairingTiming("ctkd_initiate", "bondState=${device.bondState}")
6855
- if (device.bondState == BluetoothDevice.BOND_BONDED) {
6856
- Bridge.log("LIVE: CTKD: Device is already bonded - connecting A2DP audio profile")
6857
- markPairingTiming("ctkd_already_bonded")
6858
- connectA2dpProfile(device)
6859
- } else {
6860
- createBond(device)
6858
+ when (device.bondState) {
6859
+ BluetoothDevice.BOND_BONDED -> {
6860
+ Bridge.log(
6861
+ "LIVE: CTKD: Device is already bonded - connecting A2DP audio profile"
6862
+ )
6863
+ markPairingTiming("ctkd_already_bonded")
6864
+ connectA2dpProfile(device)
6865
+ }
6866
+ BluetoothDevice.BOND_BONDING -> {
6867
+ // Secure pairing firmware may request SMP as soon as GATT connects.
6868
+ // Let that in-flight procedure finish instead of calling createBond twice.
6869
+ Bridge.log("LIVE: CTKD: Bonding already in progress - waiting for completion")
6870
+ markPairingTiming("ctkd_bonding_already_in_progress")
6871
+ }
6872
+ else -> createBond(device)
6861
6873
  }
6862
6874
  }
6863
6875
 
@@ -0,0 +1,54 @@
1
+ package com.mentra.bluetoothsdk.sgcs
2
+
3
+ internal data class MentraLivePairingAdvertisement(
4
+ val pairingMode: Boolean,
5
+ val pairingCode: String,
6
+ )
7
+
8
+ internal object MentraLivePairingAdvertisementParser {
9
+ private const val PAIRING_FLAG_OFFSET = 5
10
+ private const val PAIRING_DISCOVERABLE: Byte = 0x01
11
+ private const val PROTOCOL_VERSION_OFFSET = PAIRING_FLAG_OFFSET + 1
12
+ private const val CAPABILITY_OFFSET = PROTOCOL_VERSION_OFFSET + 1
13
+ private const val CODE_LOW_OFFSET = CAPABILITY_OFFSET + 1
14
+ private const val CODE_HIGH_OFFSET = CODE_LOW_OFFSET + 1
15
+ private const val MAGIC_FIRST_OFFSET = CODE_HIGH_OFFSET + 1
16
+ private const val MAGIC_SECOND_OFFSET = MAGIC_FIRST_OFFSET + 1
17
+ private const val PROTOCOL_VERSION_MIN = 2
18
+ private const val PROTOCOL_VERSION_MAX = 15
19
+ private const val SECURE_PAIRING_CAPABILITY = 0x01
20
+ private const val MAGIC_FIRST = 0x4D // M
21
+ private const val MAGIC_SECOND = 0x50 // P
22
+
23
+ /**
24
+ * Parses company-id-stripped 0xB822 manufacturer data.
25
+ *
26
+ * Legacy firmware stores an XOR'd Classic MAC where the original pairing implementation
27
+ * expected version and capability bytes. Requiring the `MP` marker makes the formats
28
+ * unambiguous instead of probabilistically classifying MAC bytes as a secure trailer.
29
+ */
30
+ fun parse(manufacturerData: ByteArray?): MentraLivePairingAdvertisement? {
31
+ if (manufacturerData == null || manufacturerData.size <= MAGIC_SECOND_OFFSET) {
32
+ return null
33
+ }
34
+
35
+ val version = manufacturerData[PROTOCOL_VERSION_OFFSET].toInt() and 0xFF
36
+ val capability = manufacturerData[CAPABILITY_OFFSET].toInt() and 0xFF
37
+ val magicFirst = manufacturerData[MAGIC_FIRST_OFFSET].toInt() and 0xFF
38
+ val magicSecond = manufacturerData[MAGIC_SECOND_OFFSET].toInt() and 0xFF
39
+ if (version !in PROTOCOL_VERSION_MIN..PROTOCOL_VERSION_MAX ||
40
+ (capability and SECURE_PAIRING_CAPABILITY) == 0 ||
41
+ magicFirst != MAGIC_FIRST ||
42
+ magicSecond != MAGIC_SECOND
43
+ ) {
44
+ return null
45
+ }
46
+
47
+ val codeLow = manufacturerData[CODE_LOW_OFFSET].toInt() and 0xFF
48
+ val codeHigh = manufacturerData[CODE_HIGH_OFFSET].toInt() and 0xFF
49
+ return MentraLivePairingAdvertisement(
50
+ pairingMode = manufacturerData[PAIRING_FLAG_OFFSET] == PAIRING_DISCOVERABLE,
51
+ pairingCode = String.format("%02X%02X", codeHigh, codeLow),
52
+ )
53
+ }
54
+ }
@@ -0,0 +1,13 @@
1
+ package com.mentra.bluetoothsdk
2
+
3
+ import org.junit.Assert.assertEquals
4
+ import org.junit.Test
5
+
6
+ class ReleaseChangelogTest {
7
+ @Test
8
+ fun includesTargetNotesForTransitionWithinOneReleaseTrain() {
9
+ val changelogs = ReleaseChangelogCatalog.select("3.1.0-dev.2", "3.1.0-beta.8")
10
+
11
+ assertEquals(listOf("3.1.0"), changelogs.map { it.version })
12
+ }
13
+ }