@mentra/bluetooth-sdk 3.2.0-dev.195 → 3.2.0-dev.203

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 (63) hide show
  1. package/README.md +64 -45
  2. package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkModule.kt +5 -1
  3. package/android/src/main/java/com/mentra/bluetoothsdk/Bridge.kt +77 -1
  4. package/android/src/main/java/com/mentra/bluetoothsdk/DeviceManager.kt +12 -6
  5. package/android/src/main/java/com/mentra/bluetoothsdk/GeneratedChangelogCatalog.kt +1 -1
  6. package/android/src/main/java/com/mentra/bluetoothsdk/GeneratedReleaseMetadata.kt +5 -5
  7. package/android/src/main/java/com/mentra/bluetoothsdk/MentraBluetoothSdk.kt +318 -36
  8. package/android/src/main/java/com/mentra/bluetoothsdk/MessageAckPolicy.kt +4 -0
  9. package/android/src/main/java/com/mentra/bluetoothsdk/VersionInfoResponseAccumulator.kt +88 -0
  10. package/android/src/main/java/com/mentra/bluetoothsdk/WifiRequestResolution.kt +246 -0
  11. package/android/src/main/java/com/mentra/bluetoothsdk/services/Foreground.kt +50 -16
  12. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/ClassicAudioConnectionTracker.kt +104 -0
  13. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraLive.kt +626 -120
  14. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraLiveGattLifecycle.kt +64 -0
  15. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/SGCManager.kt +2 -0
  16. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/SerializedGattCallback.kt +76 -0
  17. package/android/src/main/java/com/mentra/bluetoothsdk/status/DeviceStatus.kt +16 -3
  18. package/android/src/test/java/com/mentra/bluetoothsdk/GlassesStatusClassicAudioTest.kt +14 -0
  19. package/android/src/test/java/com/mentra/bluetoothsdk/MessageAckPolicyTest.kt +16 -0
  20. package/android/src/test/java/com/mentra/bluetoothsdk/VersionInfoBridgeTest.kt +33 -0
  21. package/android/src/test/java/com/mentra/bluetoothsdk/VersionInfoResponseAccumulatorTest.kt +169 -0
  22. package/android/src/test/java/com/mentra/bluetoothsdk/WifiRequestResolutionTest.kt +271 -0
  23. package/android/src/test/java/com/mentra/bluetoothsdk/services/ForegroundServiceManifestTest.kt +57 -0
  24. package/android/src/test/java/com/mentra/bluetoothsdk/services/ForegroundServiceTypeTest.kt +52 -0
  25. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/ClassicAudioConnectionTrackerTest.kt +131 -0
  26. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/MentraLiveGattCallbackTest.kt +94 -0
  27. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/MentraLiveGattLifecycleTest.kt +112 -0
  28. package/build/BluetoothSdk.types.d.ts +62 -1
  29. package/build/BluetoothSdk.types.d.ts.map +1 -1
  30. package/build/BluetoothSdk.types.js.map +1 -1
  31. package/build/_private/BluetoothSdkModule.d.ts +3 -2
  32. package/build/_private/BluetoothSdkModule.d.ts.map +1 -1
  33. package/build/_private/BluetoothSdkModule.js.map +1 -1
  34. package/build/generated/changelogCatalog.d.ts +1 -1
  35. package/build/generated/changelogCatalog.js +1 -1
  36. package/build/generated/changelogCatalog.js.map +1 -1
  37. package/build/generated/releaseMetadata.js +5 -5
  38. package/build/generated/releaseMetadata.js.map +1 -1
  39. package/build/index.d.ts +1 -1
  40. package/build/index.d.ts.map +1 -1
  41. package/build/index.js +3 -0
  42. package/build/index.js.map +1 -1
  43. package/ios/BluetoothSdkModule.swift +8 -2
  44. package/ios/Source/BluetoothSdkDefaults.swift +1 -1
  45. package/ios/Source/Bridge.swift +78 -1
  46. package/ios/Source/DeviceManager.swift +13 -6
  47. package/ios/Source/GeneratedChangelogCatalog.swift +1 -1
  48. package/ios/Source/GeneratedReleaseMetadata.swift +5 -5
  49. package/ios/Source/MentraBluetoothSDK.swift +397 -99
  50. package/ios/Source/WifiRequestResolution.swift +251 -0
  51. package/ios/Source/requests/VersionInfoResponseAccumulator.swift +101 -0
  52. package/ios/Source/sgcs/MentraLive.swift +179 -37
  53. package/ios/Source/sgcs/SGCManager.swift +13 -1
  54. package/ios/Source/status/DeviceStatus.swift +30 -3
  55. package/ios/Tests/VersionInfoBridgeTests.swift +32 -0
  56. package/ios/Tests/VersionInfoResponseAccumulatorTests.swift +181 -0
  57. package/ios/Tests/WifiRequestResolutionTests.swift +320 -0
  58. package/package.json +1 -1
  59. package/src/BluetoothSdk.types.ts +76 -1
  60. package/src/_private/BluetoothSdkModule.ts +4 -1
  61. package/src/generated/changelogCatalog.ts +1 -1
  62. package/src/generated/releaseMetadata.ts +5 -5
  63. package/src/index.ts +11 -0
package/README.md CHANGED
@@ -115,6 +115,24 @@ Android apps should request the permissions required by the features they use:
115
115
 
116
116
  Some Android 12+ devices require Location permission and Location services before BLE scan callbacks are delivered.
117
117
 
118
+ ### Android Foreground Service Types
119
+
120
+ The SDK service reads its allowed types from the merged Android manifest.
121
+ The default manifest retains MentraOS's connected-device, microphone, location,
122
+ media-playback and data-sync capabilities. A Bluetooth-only host can override
123
+ `com.mentra.bluetoothsdk.services.ForegroundService` to use `connectedDevice`
124
+ with `tools:replace="android:foregroundServiceType"`, and remove unused typed
125
+ FGS permissions from its final manifest. Startup then uses `connectedDevice`
126
+ instead of `dataSync`; the SDK's `CHANGE_WIFI_STATE` permission satisfies its
127
+ startup prerequisite even before a Bluetooth runtime permission is granted.
128
+
129
+ Only restrict types when the corresponding background features are unused.
130
+ Receiving the glasses' BLE audio is distinct from selecting Android's phone or
131
+ Bluetooth headset microphone. Hosts using Android microphone capture, location
132
+ tracking or background media playback must retain the corresponding types and
133
+ permissions, including those required by other native modules. At least
134
+ `connectedDevice` or `dataSync` must remain for SDK service startup.
135
+
118
136
  iOS apps should include usage descriptions:
119
137
 
120
138
  ```json
@@ -134,21 +152,19 @@ iOS apps should include usage descriptions:
134
152
  Use `scan()` when your app needs to show a picker. It calls `onResults` every time the discovered list changes, then resolves with the final list after the timeout.
135
153
 
136
154
  ```ts
137
- import BluetoothSdk, {
138
- DeviceModels,
139
- } from '@mentra/bluetooth-sdk'
155
+ import BluetoothSdk, {DeviceModels} from "@mentra/bluetooth-sdk"
140
156
 
141
157
  const devices = await BluetoothSdk.scan(DeviceModels.MentraLive, {
142
158
  timeoutMs: 10_000,
143
159
  onResults: (nextDevices) => {
144
- console.log('Nearby glasses:', nextDevices)
160
+ console.log("Nearby glasses:", nextDevices)
145
161
  },
146
162
  onDiagnostic: (hint) => showScanHint(hint.message),
147
163
  })
148
164
 
149
165
  const device = await chooseDevice(devices)
150
166
  if (!device) {
151
- throw new Error('No Mentra Live glasses selected')
167
+ throw new Error("No Mentra Live glasses selected")
152
168
  }
153
169
 
154
170
  await BluetoothSdk.connect(device)
@@ -257,9 +273,9 @@ subpath for common lifecycle plumbing. Use the root `BluetoothSdk` object for
257
273
  commands such as `requestPhoto()`, `startStream()`, and `setMicState()`.
258
274
 
259
275
  ```tsx
260
- import {Button, Text, View} from 'react-native'
261
- import {DeviceModels} from '@mentra/bluetooth-sdk'
262
- import {useBluetoothEvent, useMentraBluetooth} from '@mentra/bluetooth-sdk/react'
276
+ import {Button, Text, View} from "react-native"
277
+ import {DeviceModels} from "@mentra/bluetooth-sdk"
278
+ import {useBluetoothEvent, useMentraBluetooth} from "@mentra/bluetooth-sdk/react"
263
279
 
264
280
  export function DeviceScreen() {
265
281
  const mentra = useMentraBluetooth({
@@ -267,13 +283,13 @@ export function DeviceScreen() {
267
283
  scanTimeoutMs: 10_000,
268
284
  })
269
285
 
270
- useBluetoothEvent('button_press', (event) => {
271
- console.log('Glasses button:', event.buttonId, event.pressType)
286
+ useBluetoothEvent("button_press", (event) => {
287
+ console.log("Glasses button:", event.buttonId, event.pressType)
272
288
  })
273
289
 
274
290
  return (
275
291
  <View>
276
- <Text>{mentra.glasses.connected ? 'Connected' : 'Disconnected'}</Text>
292
+ <Text>{mentra.glasses.connected ? "Connected" : "Disconnected"}</Text>
277
293
  <Button disabled={mentra.busy} title="Scan" onPress={() => mentra.scan.start()} />
278
294
  {mentra.scan.devices.map((device) => (
279
295
  <Button key={device.id} title={device.name} onPress={() => mentra.connect(device)} />
@@ -296,11 +312,11 @@ The React hook exposes `glasses.connection` as a discriminated union:
296
312
 
297
313
  ```ts
298
314
  type GlassesConnectionStatus =
299
- | {state: 'disconnected'}
300
- | {state: 'scanning'}
301
- | {state: 'connecting'}
302
- | {state: 'bonding'}
303
- | {state: 'connected'; fullyBooted: boolean}
315
+ | {state: "disconnected"}
316
+ | {state: "scanning"}
317
+ | {state: "connecting"}
318
+ | {state: "bonding"}
319
+ | {state: "connected"; fullyBooted: boolean}
304
320
  ```
305
321
 
306
322
  Use `connection.state` for link progress. `fullyBooted` only exists when `state === 'connected'`. Android and iOS native APIs also keep `connectionState`, `connected`, and `fullyBooted` as native status properties for Kotlin and Swift callers.
@@ -356,11 +372,14 @@ await BluetoothSdk.setDashboardPosition(4, 2)
356
372
  const networks = await BluetoothSdk.requestWifiScan()
357
373
  console.log(networks.map((network) => network.ssid))
358
374
 
359
- const wifiStatus = await BluetoothSdk.sendWifiCredentials('Office WiFi', 'secret')
375
+ const savedResult = await BluetoothSdk.getSavedWifiNetworks()
376
+ if (savedResult.outcome === "confirmed") console.log(savedResult.networks)
377
+
378
+ const wifiStatus = await BluetoothSdk.sendWifiCredentials("Office WiFi", "secret")
360
379
  console.log(wifiStatus.state)
361
380
 
362
- const forgetStatus = await BluetoothSdk.forgetWifiNetwork('Office WiFi')
363
- console.log(forgetStatus.state)
381
+ const forgetResult = await BluetoothSdk.forgetWifiNetwork("Office WiFi")
382
+ console.log(forgetResult.outcome)
364
383
 
365
384
  const hotspotStatus = await BluetoothSdk.setHotspotState(true)
366
385
  console.log(hotspotStatus.state)
@@ -371,19 +390,11 @@ const galleryAck = await BluetoothSdk.setGalleryModeEnabled(true)
371
390
  console.log(galleryAck.status)
372
391
  await BluetoothSdk.setGalleryModeEnabled(false)
373
392
 
374
- await BluetoothSdk.setPreferredMic('auto')
393
+ await BluetoothSdk.setPreferredMic("auto")
375
394
  await BluetoothSdk.setMicState(true)
376
395
  await BluetoothSdk.setOwnAppAudioPlaying(false)
377
396
 
378
- const ledAck = await BluetoothSdk.rgbLedControl(
379
- `led-${Date.now()}`,
380
- 'com.example.app',
381
- 'on',
382
- 'green',
383
- 500,
384
- 500,
385
- 3,
386
- )
397
+ const ledAck = await BluetoothSdk.rgbLedControl(`led-${Date.now()}`, "com.example.app", "on", "green", 500, 500, 3)
387
398
  console.log(ledAck.state)
388
399
  ```
389
400
 
@@ -422,7 +433,15 @@ await sdk.setDashboardContent("")
422
433
  Settings commands that return `SettingsAckSuccessEvent` reject when the ASG reports an error ack. The SDK updates its local settings store only after that ASG ack resolves successfully, so observed SDK state reflects the acknowledged glasses state rather than a queued request. Raw `settings_ack` listener events still use `SettingsAckEvent` because they can include both success and failure statuses. `rgbLedControl(...)` resolves from a successful ASG `rgb_led_control_response` and rejects when the ASG reports `state: "error"`; raw `settings_ack` and `rgb_led_control_response` events remain available through listeners.
423
434
 
424
435
  WiFi, hotspot, and version-info commands resolve from the ASG response path, not local dispatch:
425
- `requestWifiScan()` resolves from the ASG `wifi_scan_result` completion response with the updated scan list, including `[]` when no networks are found. Intermediate `wifi_scan_result` events can arrive with `scanComplete: false` while the glasses stream discovered networks; the final event uses `scanComplete: true`. If older glasses stream non-empty scan results but never send the completion event, the request resolves with the accumulated scan list when the request times out. `sendWifiCredentials()` resolves when the requested SSID is connected, `forgetWifiNetwork()` resolves when that SSID is no longer connected, `setHotspotState()` resolves when the requested hotspot state is reported, and `requestVersionInfo()` resolves from the ASG `version_info` response instead of local store changes.
436
+ `requestWifiScan()` resolves from the ASG `wifi_scan_result` completion response with the updated scan list, including `[]` when no networks are found. Intermediate `wifi_scan_result` events can arrive with `scanComplete: false` while the glasses stream discovered networks; the final event uses `scanComplete: true`. If older glasses stream non-empty scan results but never send the completion event, the request resolves with the accumulated scan list when the request times out. `sendWifiCredentials()` resolves when the requested SSID is connected, `forgetWifiNetwork()` returns a semantic `WifiForgetResult`, `getSavedWifiNetworks()` returns a semantic `SavedWifiNetworksResult`, `setHotspotState()` resolves when the requested hotspot state is reported, and `requestVersionInfo()` waits for all chunks declared by the correlated response's `chunkCount`, `chunkIndex`, and `final` metadata. Legacy single-message responses complete immediately; legacy chunked responses complete on `version_info_3` after `version_info_1`. Missing final chunks time out rather than returning partial data after a quiet period.
437
+
438
+ Current Mentra Live builds advertise `wifiForgetResultVersion` and `savedWifiNetworksVersion` in `version_info_1`. Native queues calls while those capabilities are unknown, sharing one 15-second deadline for negotiation and response. A discovery timeout rejects and never guesses that the glasses are legacy. Only version 1 is supported: modern commands carry `protocolVersion: 1`, a nonempty request ID, and the glasses process session ID; legacy forget commands omit all three fields. Unknown or malformed versions fail closed. Correlated results must match the complete tuple and (for forget) exact SSID; unrelated `wifi_status` events never settle a modern request. Session changes and disconnects reject pending WiFi work instead of allowing stale responses to cross sessions.
439
+
440
+ `WifiForgetResult.outcome` is `confirmed`, `dispatched`, `not_found`, `unsupported`, `failed`, or `legacy_unverified`. K900 returns `dispatched`: ASG queued its asynchronous SystemUI broadcast, but the vendor API provides no completion callback and credential removal is not verified. `connected`, `currentSsid`, and `localIp` are best-effort diagnostic snapshot fields; `connected` is omitted when ASG cannot read the link state and must not be interpreted as `false`. Older firmware without the capability advertisement uses the isolated legacy path; accepted native dispatch immediately returns `legacy_unverified`, with no invented link-state snapshot. `getSavedWifiNetworks()` preserves exact SSID identity. It returns `confirmed` only for a backend with reliable enumeration, while K900 and legacy firmware return a typed `unsupported` result because the vendor credential store has no reliable list response.
441
+
442
+ Promise results expose semantic outcomes and optional link snapshots, not `mode`, `capabilityVersion`, `requestId`, or `sid`. A legacy result returns `legacy_unverified` immediately after actual native transport acceptance; no active or compatible glasses transport rejects with `dispatch_failed`.
443
+
444
+ Raw `wifi_forget_result` listeners receive a discriminated event union. `mode: "modern"` carries `requestId`, `sid`, `protocolVersion`, and semantic `outcome`; `mode: "legacy"` preserves the older uncorrelated wire frame's `dispatched` boolean. Partial tuples are rejected. Terminal Wi-Fi events use at-least-once BLE delivery, so raw listeners can observe retries; deduplicate modern events by `(requestId, sid)`. Promise coordinators accept only the first matching terminal frame.
426
445
 
427
446
  The SDK automatically sends the phone wall clock once shortly after a glasses connection becomes ready. It waits for the initial command burst to drain before timestamping the command so startup queue delay does not become clock skew. The once-per-connection guard resets after disconnect, so every successful reconnect synchronizes again. The SDK does not periodically verify or correct clock skew during a long-lived connection; apps that require periodic reconciliation can compare `requestVersionInfo().systemTimeMs` with the phone clock.
428
447
 
@@ -507,15 +526,15 @@ Mentra Live also rejects `ota_start` before acknowledgement when its known batte
507
526
 
508
527
  ```ts
509
528
  const photo = await BluetoothSdk.requestPhoto({
510
- size: 'medium',
511
- webhookUrl: 'https://api.example.com/mentra/photo',
512
- authToken: 'optional-token',
513
- compress: 'medium',
529
+ size: "medium",
530
+ webhookUrl: "https://api.example.com/mentra/photo",
531
+ authToken: "optional-token",
532
+ compress: "medium",
514
533
  sound: true,
515
534
  exposureTimeNs: null, // auto exposure; pass a positive nanosecond value for manual exposure
516
535
  iso: null, // auto ISO; pass a positive ISO only with manual exposureTimeNs
517
536
  })
518
- console.log('photo delivered', photo.photoUrl ?? photo.uploadUrl, photo.fileSizeBytes)
537
+ console.log("photo delivered", photo.photoUrl ?? photo.uploadUrl, photo.fileSizeBytes)
519
538
  ```
520
539
 
521
540
  `requestPhoto(...)` resolves only after the full photo action reaches terminal success: capture completed and the photo was delivered to the webhook, either directly from the glasses over Wi-Fi or through the phone's Bluetooth fallback relay. If you omit `requestId`, the SDK generates one and the terminal response includes it. It rejects if the ASG reports `state: "error"`, if phone-side fallback upload fails, if the SDK cannot send the command, or if no terminal `photo_response` arrives within 30 seconds. Photo requests use this longer operation-specific deadline because max-quality BLE fallback can legitimately exceed the 15-second deadline used by ordinary commands. Use `photo_status` for intermediate stages such as `accepted`, `configuring`, `capturing`, `captured`, `uploading`, `ble_fallback_compression`, `ready_for_transfer`, and `transferring`; `photo_status` is progress, while `photo_response` is terminal success/error. The raw `photo_response` event stream still includes both success and error events for subscribers. The webhook should accept multipart form data with a `photo` file and `requestId`. If `authToken` is provided, the uploader adds `Authorization: Bearer <token>`. The camera light is always enabled for photo capture.
@@ -536,8 +555,8 @@ The missing/factory persistent FOV base is 102 degrees with centered ROI; existi
536
555
  const streamId = `stream-${Date.now()}`
537
556
 
538
557
  await BluetoothSdk.startStream({
539
- type: 'start_stream',
540
- streamUrl: 'http://192.168.1.42:8889/mentra-live/whip',
558
+ type: "start_stream",
559
+ streamUrl: "http://192.168.1.42:8889/mentra-live/whip",
541
560
  streamId,
542
561
  video: {fps: 15},
543
562
  })
@@ -553,19 +572,19 @@ Use `rtmp://` or `rtmps://` for RTMP, `srt://` for SRT, and `http://` or `https:
553
572
  React Native components should use `useBluetoothEvent()` for hardware events:
554
573
 
555
574
  ```tsx
556
- import {useBluetoothEvent} from '@mentra/bluetooth-sdk/react'
575
+ import {useBluetoothEvent} from "@mentra/bluetooth-sdk/react"
557
576
 
558
577
  export function HardwareEventLogger() {
559
- useBluetoothEvent('button_press', (event) => console.log(event))
560
- useBluetoothEvent('touch_event', (event) => console.log(event))
561
- useBluetoothEvent('photo_status', (event) => console.log(event.status, event.resolvedConfig, event.captureMetadata))
562
- useBluetoothEvent('stream_status', (event) => console.log(event))
563
- useBluetoothEvent('speaking_status', (event) => console.log(event.speaking))
564
- useBluetoothEvent('mic_pcm', (event) => {
578
+ useBluetoothEvent("button_press", (event) => console.log(event))
579
+ useBluetoothEvent("touch_event", (event) => console.log(event))
580
+ useBluetoothEvent("photo_status", (event) => console.log(event.status, event.resolvedConfig, event.captureMetadata))
581
+ useBluetoothEvent("stream_status", (event) => console.log(event))
582
+ useBluetoothEvent("speaking_status", (event) => console.log(event.speaking))
583
+ useBluetoothEvent("mic_pcm", (event) => {
565
584
  console.log(event.sampleRate, event.bitsPerSample, event.channels, event.encoding)
566
585
  console.log(event.pcm)
567
586
  })
568
- useBluetoothEvent('mic_health', (event) => {
587
+ useBluetoothEvent("mic_health", (event) => {
569
588
  console.log(event.reason, event.sequenceGapEvents, event.decodeFailures)
570
589
  })
571
590
 
@@ -339,6 +339,8 @@ class BluetoothSdkModule : Module() {
339
339
  "local_transcription",
340
340
  "wifi_status_change",
341
341
  "wifi_scan_result",
342
+ "wifi_forget_result",
343
+ "saved_wifi_networks",
342
344
  "hotspot_status_change",
343
345
  "hotspot_error",
344
346
  "photo_response",
@@ -592,12 +594,14 @@ class BluetoothSdkModule : Module() {
592
594
 
593
595
  SdkCoroutineFunction("requestWifiScan") { -> requireSdk().requestWifiScan().map { it.toMap() } }
594
596
 
597
+ SdkCoroutineFunction("getSavedWifiNetworks") { -> requireSdk().getSavedWifiNetworks().toMap() }
598
+
595
599
  SdkCoroutineFunction("sendWifiCredentials") { ssid: String, password: String ->
596
600
  requireSdk().sendWifiCredentials(ssid, password).values
597
601
  }
598
602
 
599
603
  SdkCoroutineFunction("forgetWifiNetwork") { ssid: String ->
600
- requireSdk().forgetWifiNetwork(ssid).values
604
+ requireSdk().forgetWifiNetwork(ssid).toMap()
601
605
  }
602
606
 
603
607
  SdkCoroutineFunction("setHotspotState") { enabled: Boolean ->
@@ -476,11 +476,26 @@ public class Bridge private constructor() {
476
476
  }
477
477
 
478
478
  @JvmStatic
479
- fun sendVersionInfo(values: Map<String, Any>) {
479
+ @JvmOverloads
480
+ fun sendVersionInfo(values: Map<String, Any>, responseChunk: String = "version_info") {
480
481
  fun stringField(vararg keys: String): String =
481
482
  keys.firstNotNullOfOrNull { key -> values[key] as? String } ?: ""
482
483
  val body = HashMap<String, Any>()
483
484
  body["type"] = "version_info"
485
+ body[VersionInfoResponseAccumulator.RESPONSE_CHUNK_KEY] = responseChunk
486
+ mapOf(
487
+ "chunkIndex" to VersionInfoResponseAccumulator.RESPONSE_INDEX_KEY,
488
+ "chunkCount" to VersionInfoResponseAccumulator.RESPONSE_COUNT_KEY,
489
+ "final" to VersionInfoResponseAccumulator.RESPONSE_FINAL_KEY,
490
+ "sid" to VersionInfoResponseAccumulator.RESPONSE_SID_KEY,
491
+ ).forEach { (wireKey, internalKey) -> values[wireKey]?.let { body[internalKey] = it } }
492
+ (values["requestId"] as? String ?: values["request_id"] as? String)
493
+ ?.takeIf { it.isNotEmpty() }
494
+ ?.let {
495
+ body[VersionInfoResponseAccumulator.RESPONSE_REQUEST_ID_KEY] = it
496
+ }
497
+ body["versionInfoType"] = stringField("versionInfoType", "version_info_type")
498
+ body["sid"] = stringField("sid")
484
499
  body["androidVersion"] = stringField("androidVersion", "android_version")
485
500
  body["firmwareVersion"] = stringField("firmwareVersion", "firmware_version")
486
501
  body["besFirmwareVersion"] = stringField("besFirmwareVersion", "bes_fw_version")
@@ -499,6 +514,15 @@ public class Bridge private constructor() {
499
514
  (values["hotspotOtaVersion"] as? Number
500
515
  ?: values["hotspot_ota_version"] as? Number)
501
516
  ?.let { body["hotspotOtaVersion"] = it.toInt() }
517
+ for ((key, wireKey) in listOf(
518
+ "wifiForgetResultVersion" to "wifi_forget_result_version",
519
+ "savedWifiNetworksVersion" to "saved_wifi_networks_version",
520
+ )) {
521
+ if (values.containsKey(key) || values.containsKey(wireKey)) {
522
+ // Preserve malformed presence: it must not become legacy or be rounded to v1.
523
+ body[key] = values[key] ?: values[wireKey] ?: -1
524
+ }
525
+ }
502
526
  sendTypedMessage("version_info", body)
503
527
  }
504
528
 
@@ -579,6 +603,58 @@ public class Bridge private constructor() {
579
603
  sendTypedMessage("wifi_status_change", payload)
580
604
  }
581
605
 
606
+ @JvmStatic
607
+ fun sendWifiForgetResult(
608
+ requestId: String,
609
+ sid: String,
610
+ ssid: String,
611
+ protocolVersion: Int,
612
+ outcome: String,
613
+ legacyDispatched: Boolean?,
614
+ connected: Boolean?,
615
+ currentSsid: String,
616
+ localIp: String,
617
+ error: String?,
618
+ ) {
619
+ val body =
620
+ normalizeWifiForgetResultEvent(
621
+ requestId,
622
+ sid,
623
+ ssid,
624
+ protocolVersion,
625
+ outcome,
626
+ legacyDispatched,
627
+ connected,
628
+ currentSsid,
629
+ localIp,
630
+ error,
631
+ )
632
+ if (body == null) {
633
+ log("Dropping malformed wifi_forget_result without modern or legacy fields")
634
+ return
635
+ }
636
+ sendTypedMessage("wifi_forget_result", body)
637
+ }
638
+
639
+ @JvmStatic
640
+ fun sendSavedWifiNetworks(
641
+ requestId: String,
642
+ sid: String,
643
+ protocolVersion: Int,
644
+ outcome: String,
645
+ networks: List<String>,
646
+ error: String?,
647
+ ) {
648
+ val body = HashMap<String, Any>()
649
+ body["requestId"] = requestId
650
+ body["sid"] = sid
651
+ body["protocolVersion"] = protocolVersion
652
+ body["outcome"] = outcome
653
+ body["networks"] = networks
654
+ if (error != null) body["error"] = error
655
+ sendTypedMessage("saved_wifi_networks", body)
656
+ }
657
+
582
658
  /**
583
659
  * Claim the WiFi scan-results store for a newly requested scan. Called by the
584
660
  * SDK when it generates the scanId, BEFORE the scan command goes out: store
@@ -1784,6 +1784,9 @@ class DeviceManager {
1784
1784
  sgc?.requestWifiScan(scanId)
1785
1785
  }
1786
1786
 
1787
+ fun requestSavedWifiNetworks(requestId: String, sid: String): Boolean =
1788
+ sgc?.requestSavedWifiNetworks(requestId, sid) ?: false
1789
+
1787
1790
  fun sendIncidentId(incidentId: String, apiBaseUrl: String? = null) {
1788
1791
  Bridge.log("MAN: Sending incidentId to glasses for log upload: $incidentId")
1789
1792
  sgc?.sendIncidentId(incidentId, apiBaseUrl)
@@ -1802,10 +1805,8 @@ class DeviceManager {
1802
1805
  sgc?.sendWifiCredentials(ssid, password)
1803
1806
  }
1804
1807
 
1805
- fun forgetWifiNetwork(ssid: String) {
1806
- Bridge.log("MAN: Forgetting wifi network: $ssid")
1807
- sgc?.forgetWifiNetwork(ssid)
1808
- }
1808
+ fun forgetWifiNetwork(ssid: String, requestId: String? = null, sid: String? = null): Boolean =
1809
+ sgc?.forgetWifiNetwork(ssid, requestId, sid) ?: false
1809
1810
 
1810
1811
  fun setHotspotState(enabled: Boolean) {
1811
1812
  Bridge.log("MAN: Setting glasses hotspot state: $enabled")
@@ -2016,9 +2017,14 @@ class DeviceManager {
2016
2017
  * Request version info from glasses. Glasses will respond with version_info message containing
2017
2018
  * build number, firmware version, etc.
2018
2019
  */
2019
- fun requestVersionInfo() {
2020
+ fun requestVersionInfo(requestId: String? = null) {
2020
2021
  Bridge.log("MAN: 📱 Requesting version info from glasses")
2021
- sgc?.requestVersionInfo()
2022
+ val controller = sgc
2023
+ if (controller is MentraLive) {
2024
+ controller.requestVersionInfo(requestId)
2025
+ } else {
2026
+ controller?.requestVersionInfo()
2027
+ }
2022
2028
  }
2023
2029
 
2024
2030
  /** Send shutdown command to glasses. This will initiate a graceful shutdown of the device. */
@@ -3,6 +3,6 @@ package com.mentra.bluetoothsdk
3
3
  /** Generated from /changelogs. Do not edit directly. */
4
4
  internal val GENERATED_RELEASE_CHANGELOGS: List<ReleaseChangelog> =
5
5
  listOf(
6
- ReleaseChangelog(version = "3.2.0", markdown = "This release is under active development. User-facing changes will be documented as they land."),
6
+ ReleaseChangelog(version = "3.2.0", markdown = "This release is under active development. User-facing changes will be documented as they land.\n\n- Breaking Bluetooth SDK change: `forgetWifiNetwork(ssid)` now returns `WifiForgetResult` instead of `WifiStatusChangeEvent`. Migrate status-only consumers to the semantic `outcome`; the requested network remains `ssid`, while optional post-command connectivity is reported by `connected`, `currentSsid`, and `localIp`."),
7
7
  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."),
8
8
  )
@@ -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.2.0"
6
- const val RELEASE_IDENTITY: String = "3.2.0-dev.195"
7
- const val RELEASE_SET_ID: String = "mentra-3.2.0-dev.195"
8
- const val SOURCE_COMMIT: String = "321202ca96ec1852be19a7230ebcc3593fe87e54"
9
- const val OTA_MANIFEST_URL: String = "https://github.com/Mentra-Community/MentraOS/releases/download/mentra-builds-v3.2.0/mentra-live-ota-3.2.0-dev.195.json"
10
- const val OTA_MANIFEST_SHA256: String = "70f26063b2c281b9b046965f6f6d7ca22f8b53242c6c5be02030c5a04c7115e4"
6
+ const val RELEASE_IDENTITY: String = "3.2.0-dev.203"
7
+ const val RELEASE_SET_ID: String = "mentra-3.2.0-dev.203"
8
+ const val SOURCE_COMMIT: String = "7eb4deefff27d498c2b4562d628607f399aa1f40"
9
+ const val OTA_MANIFEST_URL: String = "https://github.com/Mentra-Community/MentraOS/releases/download/mentra-builds-v3.2.0/mentra-live-ota-3.2.0-dev.203.json"
10
+ const val OTA_MANIFEST_SHA256: String = "0ae026238c8e70970be4e181cc1ae73dd905418292edebddb610012f4f0a9b6f"
11
11
  }