@mentra/bluetooth-sdk 3.2.0-dev.180 → 3.2.0-dev.187

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 (61) hide show
  1. package/README.md +29 -0
  2. package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkModule.kt +21 -0
  3. package/android/src/main/java/com/mentra/bluetoothsdk/ConnectedDeviceMatcher.kt +69 -0
  4. package/android/src/main/java/com/mentra/bluetoothsdk/DeviceManager.kt +8 -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 +66 -1
  7. package/android/src/main/java/com/mentra/bluetoothsdk/NativeNotifications.kt +42 -0
  8. package/android/src/main/java/com/mentra/bluetoothsdk/connection/ConnectionModels.kt +38 -1
  9. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/EvenFileService.kt +101 -0
  10. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/EvenNotificationProtocol.kt +113 -0
  11. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/G2.kt +378 -24
  12. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/NotificationIds.kt +19 -0
  13. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/SGCManager.kt +11 -0
  14. package/android/src/test/java/com/mentra/bluetoothsdk/ConnectedDeviceMatcherTest.kt +161 -0
  15. package/android/src/test/java/com/mentra/bluetoothsdk/ScanCompletionTest.kt +87 -0
  16. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/EvenFileServiceTest.kt +153 -0
  17. package/build/BluetoothSdk.types.d.ts +57 -0
  18. package/build/BluetoothSdk.types.d.ts.map +1 -1
  19. package/build/BluetoothSdk.types.js.map +1 -1
  20. package/build/_private/BluetoothSdkModule.d.ts +6 -1
  21. package/build/_private/BluetoothSdkModule.d.ts.map +1 -1
  22. package/build/_private/BluetoothSdkModule.js +28 -49
  23. package/build/_private/BluetoothSdkModule.js.map +1 -1
  24. package/build/_private/scanSession.d.ts +21 -0
  25. package/build/_private/scanSession.d.ts.map +1 -0
  26. package/build/_private/scanSession.js +75 -0
  27. package/build/_private/scanSession.js.map +1 -0
  28. package/build/generated/releaseMetadata.js +5 -5
  29. package/build/generated/releaseMetadata.js.map +1 -1
  30. package/build/index.d.ts +2 -1
  31. package/build/index.d.ts.map +1 -1
  32. package/build/index.js +4 -0
  33. package/build/index.js.map +1 -1
  34. package/build/react/useBluetoothScan.d.ts +2 -1
  35. package/build/react/useBluetoothScan.d.ts.map +1 -1
  36. package/build/react/useBluetoothScan.js +13 -0
  37. package/build/react/useBluetoothScan.js.map +1 -1
  38. package/build/react/useMentraBluetooth.d.ts +2 -1
  39. package/build/react/useMentraBluetooth.d.ts.map +1 -1
  40. package/build/react/useMentraBluetooth.js +1 -0
  41. package/build/react/useMentraBluetooth.js.map +1 -1
  42. package/ios/BluetoothSdkModule.swift +30 -0
  43. package/ios/Source/BluetoothSdkDefaults.swift +1 -1
  44. package/ios/Source/GeneratedReleaseMetadata.swift +5 -5
  45. package/ios/Source/MentraBluetoothSDK.swift +53 -2
  46. package/ios/Source/NativeNotifications.swift +52 -0
  47. package/ios/Source/connection/ScanSession.swift +11 -0
  48. package/ios/Source/internal/BluetoothAvailability.swift +7 -0
  49. package/ios/Source/internal/ConnectedDeviceMatcher.swift +45 -0
  50. package/ios/Source/sgcs/G2.swift +131 -21
  51. package/ios/Source/sgcs/SGCManager.swift +22 -0
  52. package/ios/Tests/ConnectedDeviceMatcherTests.swift +27 -0
  53. package/ios/Tests/NativeNotificationTests.swift +20 -0
  54. package/package.json +1 -1
  55. package/src/BluetoothSdk.types.ts +62 -0
  56. package/src/_private/BluetoothSdkModule.ts +47 -57
  57. package/src/_private/scanSession.ts +88 -0
  58. package/src/generated/releaseMetadata.ts +5 -5
  59. package/src/index.ts +7 -0
  60. package/src/react/useBluetoothScan.ts +12 -1
  61. package/src/react/useMentraBluetooth.ts +2 -0
@@ -0,0 +1,161 @@
1
+ package com.mentra.bluetoothsdk
2
+
3
+ import org.assertj.core.api.Assertions.assertThat
4
+ import org.junit.Test
5
+
6
+ class ConnectedDeviceMatcherTest {
7
+ private val savedLive =
8
+ Device(
9
+ model = DeviceModel.MENTRA_LIVE,
10
+ name = "Mentra Live 4X2A",
11
+ address = "AA:BB:CC:DD:EE:FF",
12
+ )
13
+
14
+ @Test
15
+ fun `matches Mentra Live advertised name prefixes`() {
16
+ for (name in listOf("Xy_A", "XyBLE_1234", "MENTRA_LIVE_BLE_9", "MENTRA_LIVE_BT_9", "mentra_live_abc")) {
17
+ assertThat(
18
+ ConnectedDeviceMatcher.matches(
19
+ model = DeviceModel.MENTRA_LIVE,
20
+ defaultDevice = null,
21
+ candidateName = name,
22
+ candidateAddress = null,
23
+ ),
24
+ ).describedAs(name).isTrue()
25
+ }
26
+ }
27
+
28
+ @Test
29
+ fun `matches Mentra Nex advertised name prefixes`() {
30
+ for (name in listOf("Nex1-77", "MENTRA_DISPLAY_02")) {
31
+ assertThat(
32
+ ConnectedDeviceMatcher.matches(
33
+ model = DeviceModel.MENTRA_NEX,
34
+ defaultDevice = null,
35
+ candidateName = name,
36
+ candidateAddress = null,
37
+ ),
38
+ ).describedAs(name).isTrue()
39
+ }
40
+ }
41
+
42
+ @Test
43
+ fun `name prefixes are scoped to the scanned model`() {
44
+ // A held Mentra Live can never explain an empty Nex (or G1) scan.
45
+ assertThat(
46
+ ConnectedDeviceMatcher.matches(
47
+ model = DeviceModel.MENTRA_NEX,
48
+ defaultDevice = null,
49
+ candidateName = "XyBLE_1234",
50
+ candidateAddress = null,
51
+ ),
52
+ ).isFalse()
53
+ assertThat(
54
+ ConnectedDeviceMatcher.matches(
55
+ model = DeviceModel.G1,
56
+ defaultDevice = null,
57
+ candidateName = "MENTRA_LIVE_BT_9",
58
+ candidateAddress = null,
59
+ ),
60
+ ).isFalse()
61
+ }
62
+
63
+ @Test
64
+ fun `rejects non-Mentra peripherals`() {
65
+ for (name in listOf("Even G1_22_L_", "Pixel Watch", "MX Master 3S")) {
66
+ assertThat(
67
+ ConnectedDeviceMatcher.matches(
68
+ model = DeviceModel.MENTRA_LIVE,
69
+ defaultDevice = savedLive,
70
+ candidateName = name,
71
+ candidateAddress = "11:22:33:44:55:66",
72
+ ),
73
+ ).describedAs(name).isFalse()
74
+ }
75
+ }
76
+
77
+ @Test
78
+ fun `matches the saved default by exact name`() {
79
+ assertThat(
80
+ ConnectedDeviceMatcher.matches(
81
+ model = DeviceModel.MENTRA_LIVE,
82
+ defaultDevice = savedLive,
83
+ candidateName = "Mentra Live 4X2A",
84
+ candidateAddress = null,
85
+ ),
86
+ ).isTrue()
87
+ }
88
+
89
+ @Test
90
+ fun `matches the saved default by address ignoring case`() {
91
+ // Stored addresses can arrive lowercase from the JS side.
92
+ assertThat(
93
+ ConnectedDeviceMatcher.matches(
94
+ model = DeviceModel.MENTRA_LIVE,
95
+ defaultDevice = savedLive.copy(address = "aa:bb:cc:dd:ee:ff"),
96
+ candidateName = null,
97
+ candidateAddress = "AA:BB:CC:DD:EE:FF",
98
+ ),
99
+ ).isTrue()
100
+ }
101
+
102
+ @Test
103
+ fun `saved default only counts for its own model`() {
104
+ // Scanning for G1 while the saved default is a Mentra Live.
105
+ assertThat(
106
+ ConnectedDeviceMatcher.matches(
107
+ model = DeviceModel.G1,
108
+ defaultDevice = savedLive,
109
+ candidateName = "Mentra Live 4X2A",
110
+ candidateAddress = "AA:BB:CC:DD:EE:FF",
111
+ ),
112
+ ).isFalse()
113
+ }
114
+
115
+ @Test
116
+ fun `models without a branded name rely on the saved default`() {
117
+ val savedG1 = Device(model = DeviceModel.G1, name = "Even G1_22_L_", address = null)
118
+ assertThat(
119
+ ConnectedDeviceMatcher.matches(
120
+ model = DeviceModel.G1,
121
+ defaultDevice = savedG1,
122
+ candidateName = "Even G1_22_L_",
123
+ candidateAddress = null,
124
+ ),
125
+ ).isTrue()
126
+ }
127
+
128
+ @Test
129
+ fun `simulated model never matches`() {
130
+ assertThat(
131
+ ConnectedDeviceMatcher.matches(
132
+ model = DeviceModel.SIMULATED,
133
+ defaultDevice = savedLive,
134
+ candidateName = "Mentra Live 4X2A",
135
+ candidateAddress = "AA:BB:CC:DD:EE:FF",
136
+ ),
137
+ ).isFalse()
138
+ }
139
+ @Test
140
+ fun `blank saved identities never identify an unrelated connection`() {
141
+ val saved = Device(DeviceModel.G1, name = "", address = "")
142
+ assertThat(ConnectedDeviceMatcher.matches(DeviceModel.G1, saved, "", "")).isFalse()
143
+ assertThat(ConnectedDeviceMatcher.matches(DeviceModel.G1, saved, null, null)).isFalse()
144
+ }
145
+
146
+ @Test
147
+ fun `different known addresses override a matching saved name`() {
148
+ val saved = Device(DeviceModel.G1, name = "Even G1", address = "AA:BB:CC:DD:EE:FF")
149
+ assertThat(
150
+ ConnectedDeviceMatcher.matches(DeviceModel.G1, saved, "Even G1", "11:22:33:44:55:66"),
151
+ ).isFalse()
152
+ }
153
+
154
+ @Test
155
+ fun `a branded candidate can still match its model without being the saved device`() {
156
+ assertThat(
157
+ ConnectedDeviceMatcher.matches(DeviceModel.MENTRA_LIVE, savedLive, "XyBLE_5678", "11:22:33:44:55:66"),
158
+ ).isTrue()
159
+ }
160
+
161
+ }
@@ -0,0 +1,87 @@
1
+ package com.mentra.bluetoothsdk
2
+
3
+ import org.assertj.core.api.Assertions.assertThat
4
+ import org.junit.Test
5
+
6
+ class ScanCompletionTest {
7
+ private val hint = ScanDiagnostic("device_connected_on_phone", "A matching device is connected to this phone.")
8
+
9
+ @Test
10
+ fun `empty completed scan delivers diagnostic then completion without error`() {
11
+ val events = mutableListOf<String>()
12
+ val callback = object : MentraBluetoothScanCallback() {
13
+ override fun onDiagnostic(diagnostic: ScanDiagnostic) {
14
+ assertThat(diagnostic).isEqualTo(hint)
15
+ events.add("diagnostic")
16
+ }
17
+ override fun onComplete(devices: List<Device>) {
18
+ assertThat(devices).isEmpty()
19
+ events.add("complete")
20
+ }
21
+ override fun onError(error: BluetoothError) {
22
+ events.add("error")
23
+ }
24
+ }
25
+ callback.completeScan(ScanStopReason.COMPLETED, emptyList()) { hint }
26
+ assertThat(events).containsExactly("diagnostic", "complete")
27
+ }
28
+
29
+ @Test
30
+ fun `legacy callbacks complete without querying diagnostics`() {
31
+ var completed = false
32
+ // Implement only the pre-existing interface; it gains no abstract method.
33
+ val callback = object : ScanCallback {
34
+ override fun onComplete(devices: List<Device>) { completed = true }
35
+ }
36
+ callback.completeScan(ScanStopReason.COMPLETED, emptyList()) {
37
+ error("Legacy callbacks must not query diagnostics")
38
+ }
39
+ assertThat(completed).isTrue()
40
+ assertThat(ScanCallback::class.java.declaredMethods.map { it.name })
41
+ .containsExactlyInAnyOrder("onResults", "onComplete", "onError")
42
+ }
43
+
44
+ @Test
45
+ fun `cancelled scans and nonempty scans never query diagnostics`() {
46
+ val device = Device(DeviceModel.MENTRA_LIVE, "XyBLE_1234")
47
+ val completed = mutableListOf<List<Device>>()
48
+ val callback = object : MentraBluetoothScanCallback() {
49
+ override fun onComplete(devices: List<Device>) { completed.add(devices) }
50
+ override fun onDiagnostic(diagnostic: ScanDiagnostic) { error("Unexpected diagnostic") }
51
+ }
52
+ for ((reason, devices) in listOf(
53
+ ScanStopReason.CANCELLED to emptyList(),
54
+ ScanStopReason.COMPLETED to listOf(device),
55
+ )) {
56
+ callback.completeScan(reason, devices) { error("Unexpected diagnostic query") }
57
+ }
58
+ assertThat(completed).containsExactly(emptyList(), listOf(device))
59
+ }
60
+
61
+ @Test
62
+ fun `missing diagnostic leaves an ordinary empty completion`() {
63
+ val events = mutableListOf<String>()
64
+ val callback = object : MentraBluetoothScanCallback() {
65
+ override fun onComplete(devices: List<Device>) { events.add("complete") }
66
+ override fun onDiagnostic(diagnostic: ScanDiagnostic) { events.add("diagnostic") }
67
+ override fun onError(error: BluetoothError) { events.add("error") }
68
+ }
69
+ callback.completeScan(ScanStopReason.COMPLETED, emptyList()) { null }
70
+ assertThat(events).containsExactly("complete")
71
+ }
72
+
73
+ @Test
74
+ fun `completion still runs if the diagnostic handler throws`() {
75
+ var completed = false
76
+ val failure = IllegalStateException("consumer diagnostic handler failed")
77
+ val callback = object : MentraBluetoothScanCallback() {
78
+ override fun onComplete(devices: List<Device>) { completed = true }
79
+ override fun onDiagnostic(diagnostic: ScanDiagnostic) { throw failure }
80
+ }
81
+ val thrown = runCatching {
82
+ callback.completeScan(ScanStopReason.COMPLETED, emptyList()) { hint }
83
+ }.exceptionOrNull()
84
+ assertThat(thrown).isSameAs(failure)
85
+ assertThat(completed).isTrue()
86
+ }
87
+ }
@@ -0,0 +1,153 @@
1
+ package com.mentra.bluetoothsdk.sgcs
2
+
3
+ import com.mentra.bluetoothsdk.NativeNotificationConfig
4
+ import java.io.IOException
5
+ import kotlinx.coroutines.CompletableDeferred
6
+ import kotlinx.coroutines.async
7
+ import kotlinx.coroutines.cancelAndJoin
8
+ import kotlinx.coroutines.launch
9
+ import kotlinx.coroutines.runBlocking
10
+ import org.junit.Assert.*
11
+ import org.junit.Test
12
+
13
+ class EvenFileServiceTest {
14
+ @Test fun `upload arms fast acknowledgements and only checks result after data`() = runBlocking {
15
+ lateinit var files: EvenFileService
16
+ val frames = mutableListOf<Pair<Byte, ByteArray>>()
17
+ files = EvenFileService { service, data ->
18
+ frames += service to data
19
+ when {
20
+ service == 0xC5.toByte() -> files.acceptAck(byteArrayOf(1, 0))
21
+ data[0] == 0.toByte() -> files.acceptAck(byteArrayOf(0, 0))
22
+ data[0] == 2.toByte() -> files.acceptAck(byteArrayOf(2, 0))
23
+ }
24
+ }
25
+ assertEquals(0, files.transfer(1, FileService.PATH_NOTIFY, byteArrayOf(7, 8)))
26
+ assertEquals(listOf(0xC4.toByte(), 0xC4.toByte(), 0xC5.toByte(), 0xC4.toByte()), frames.map { it.first })
27
+ assertEquals(93, frames[0].second.size)
28
+ assertArrayEquals(byteArrayOf(7, 8), frames[2].second)
29
+ assertFalse(files.needsReconnect)
30
+ }
31
+
32
+ @Test fun `start refusal stops before sending content`() = runBlocking {
33
+ lateinit var files: EvenFileService
34
+ var writes = 0
35
+ files = EvenFileService { _, _ -> writes++; files.acceptAck(byteArrayOf(0, 1)) }
36
+ assertEquals(1, files.transfer(1, "n", byteArrayOf(1)))
37
+ assertEquals(1, writes)
38
+ }
39
+
40
+ @Test fun `CRC rejection is observable and never reported delivered`() = runBlocking {
41
+ lateinit var files: EvenFileService
42
+ files = EvenFileService { service, data ->
43
+ val cid = if (service == 0xC5.toByte()) 1 else data[0].toInt()
44
+ if (service == 0xC5.toByte() || cid != 1) files.acceptAck(byteArrayOf(cid.toByte(), if (cid == 2) 2 else 0))
45
+ }
46
+ assertEquals(2, files.transfer(1, "n", byteArrayOf(9)))
47
+ }
48
+
49
+ @Test fun `wrong phase and echoed payload cannot satisfy a pending ACK`() = runBlocking {
50
+ val sent = CompletableDeferred<Unit>()
51
+ val files = EvenFileService { _, _ -> sent.complete(Unit) }
52
+ val transfer = async { files.transfer(1, "n", byteArrayOf(1)) }
53
+ sent.await()
54
+ files.acceptAck(byteArrayOf(1, 0))
55
+ files.acceptAck(byteArrayOf(0, 0, 0))
56
+ assertFalse(transfer.isCompleted)
57
+ transfer.cancelAndJoin()
58
+ assertTrue(files.needsReconnect)
59
+ }
60
+
61
+ @Test fun `disable cancels wait and blocks ambiguous reuse until reconnect`() = runBlocking {
62
+ val sent = CompletableDeferred<Unit>()
63
+ val files = EvenFileService { _, _ -> sent.complete(Unit) }
64
+ val transfer = launch { files.transfer(1, "n", byteArrayOf(1)) }
65
+ sent.await(); transfer.cancelAndJoin()
66
+ files.acceptAck(byteArrayOf(0, 0)) // late old response
67
+ try { files.transfer(1, "n", byteArrayOf(2)); fail("ambiguous channel was reused") } catch (_: IllegalStateException) {}
68
+ files.resetConnection()
69
+ assertFalse(files.needsReconnect)
70
+ }
71
+
72
+ @Test fun `connection reset cancels old wait without tainting new connection`() = runBlocking {
73
+ val sent = CompletableDeferred<Unit>()
74
+ val files = EvenFileService { _, _ -> sent.complete(Unit) }
75
+ val transfer = launch { files.transfer(1, "n", byteArrayOf(1)) }
76
+ sent.await(); files.resetConnection(); transfer.join()
77
+ assertTrue(transfer.isCancelled)
78
+ assertFalse(files.needsReconnect)
79
+ }
80
+
81
+ @Test fun `ack timeout prevents the next upload from consuming a late response`() = runBlocking {
82
+ val files = EvenFileService(timeoutMs = 1) { _, _ -> }
83
+ try { files.transfer(1, "n", byteArrayOf(1)); fail("expected timeout") } catch (_: IOException) {}
84
+ assertTrue(files.needsReconnect)
85
+ }
86
+
87
+ @Test fun `start struct encodes unsigned little endian sizes and zero padded filename`() {
88
+ val data = FileService.sendStart(1, 0x01020304, 0x11223344, "n")
89
+ assertEquals(93, data.size)
90
+ assertArrayEquals(byteArrayOf(0, 1, 0, 0, 0, 4, 3, 2, 1, 0x44, 0x33, 0x22, 0x11), data.copyOfRange(0, 13))
91
+ assertEquals('n'.code.toByte(), data[13])
92
+ assertTrue(data.drop(14).all { it == 0.toByte() })
93
+ }
94
+
95
+ @Test fun `corrupt or truncated BLE acknowledgements cannot complete a transfer`() {
96
+ val valid = byteArrayOf(0xAA.toByte(), 0x12, 1, 4, 1, 1, 0xC4.toByte(), 0, 0, 0, 0x0F, 0x1D)
97
+ assertArrayEquals(byteArrayOf(0, 0), EvenFileService.decodeAckFrame(valid))
98
+ assertNull(EvenFileService.decodeAckFrame(valid.copyOf(11)))
99
+ valid[10] = 0
100
+ assertNull(EvenFileService.decodeAckFrame(valid))
101
+ }
102
+
103
+ @Test fun `Even CRC uses the non reflected zero seed variant`() {
104
+ assertEquals(0, EvenFileService.crc32(byteArrayOf()))
105
+ assertEquals(0xC052A8C8.toInt(), EvenFileService.crc32("123456789".toByteArray()))
106
+ }
107
+
108
+ @Test fun `updates reuse ids without colliding with numeric phone ids`() {
109
+ val ids = NotificationIds()
110
+ val first = ids.forPhoneId("app-key")
111
+ assertEquals(first, ids.forPhoneId("app-key"))
112
+ assertNotEquals(first, ids.forPhoneId(first.toString()))
113
+ assertNotEquals(ids.forPhoneId(""), ids.forPhoneId(""))
114
+ repeat(1000) { ids.forPhoneId("other-$it") }
115
+ assertEquals(first, ids.forPhoneId("app-key"))
116
+ }
117
+
118
+ @Test fun `capacity never evicts known ids or reuses anonymous ids`() {
119
+ val ids = NotificationIds()
120
+ val known = ids.forPhoneId("retained-card")
121
+ val allocated = mutableSetOf(known)
122
+ repeat(7999) { assertTrue(allocated.add(ids.forPhoneId(""))) }
123
+ assertEquals(8000, allocated.size)
124
+ assertEquals(2000, allocated.minOrNull())
125
+ assertEquals(9999, allocated.maxOrNull())
126
+ assertEquals("notification_id_capacity_exhausted",
127
+ assertThrows(IllegalStateException::class.java) { ids.forPhoneId("new-card") }.message)
128
+ assertThrows(IllegalStateException::class.java) { ids.forPhoneId("") }
129
+ assertEquals(known, ids.forPhoneId("retained-card"))
130
+ }
131
+
132
+ @Test fun `all named ids remain stable when capacity is exhausted`() {
133
+ val ids = NotificationIds()
134
+ val allocated = (0 until 8000).associate { it.toString() to ids.forPhoneId(it.toString()) }
135
+ assertEquals(8000, allocated.values.toSet().size)
136
+ assertThrows(IllegalStateException::class.java) { ids.forPhoneId("new") }
137
+ allocated.forEach { (key, value) -> assertEquals(value, ids.forPhoneId(key)) }
138
+ }
139
+
140
+ @Test fun `disabled controls stop presentation without changing the whitelist`() {
141
+ val commands = NotificationProto.controls(NativeNotificationConfig(enabled = false), 11, 12)
142
+ assertEquals(listOf(11), commands.map { it.first })
143
+ assertArrayEquals(byteArrayOf(8, 1, 16, 11, 26, 8, 8, 0, 16, 1, 24, 5, 40, 0), commands.single().second)
144
+ }
145
+
146
+ @Test fun `enabled controls include the phone-side filtering override`() {
147
+ val commands = NotificationProto.controls(NativeNotificationConfig(enabled = true), 11, 12)
148
+ assertEquals(listOf(11, 12), commands.map { it.first })
149
+ assertArrayEquals(byteArrayOf(8, 1, 16, 11, 26, 8, 8, 1, 16, 1, 24, 5, 40, 0), commands.first().second)
150
+ assertArrayEquals(byteArrayOf(8, 3, 16, 12, 50, 2, 8, 1), commands.last().second)
151
+ }
152
+
153
+ }
@@ -776,6 +776,8 @@ export type BluetoothSdkModuleEvents = {
776
776
  speaking_status: (event: SpeakingStatusEvent) => void;
777
777
  battery_status: (event: BatteryStatusEvent) => void;
778
778
  local_transcription: (event: LocalTranscriptionEvent) => void;
779
+ native_notification_status: (event: NativeNotificationStatus) => void;
780
+ native_notification_delivery: (event: NativeNotificationDelivery) => void;
779
781
  phone_notification: (event: PhoneNotificationEvent) => void;
780
782
  phone_notification_dismissed: (event: PhoneNotificationDismissedEvent) => void;
781
783
  wifi_status_change: (event: WifiStatusChangeEvent) => void;
@@ -851,9 +853,55 @@ export interface PhoneNotificationDismissedEvent {
851
853
  packageName: string;
852
854
  timestamp: number;
853
855
  }
856
+ /**
857
+ * Outbound payload for `sendPhoneNotification` — a notification pushed INTO the glasses' own
858
+ * notification centre. The inverse of {@link PhoneNotificationEvent}, which reports
859
+ * notifications the glasses relayed TO the phone (iOS/ANCS). Drivers map these keys onto
860
+ * whatever their firmware expects.
861
+ */
862
+ export interface NativeNotificationConfig {
863
+ enabled: boolean;
864
+ autoDisplay: boolean;
865
+ durationSeconds: number;
866
+ doNotDisturb: boolean;
867
+ /** Android package names. iOS rejects nonempty lists; its ANCS filter is firmware-owned. */
868
+ blockedApps: string[];
869
+ }
870
+ export interface NativeNotificationStatus {
871
+ supported: boolean;
872
+ source: "phone" | "ancs" | "unsupported";
873
+ authorization: "system" | "authorized" | "not_authorized" | "unknown";
874
+ /** `submitted` is not a firmware-confirmed acknowledgement. */
875
+ state: "unavailable" | "disabled" | "configuring" | "submitted" | "needs_reconnect" | "failed";
876
+ config: NativeNotificationConfig;
877
+ error: string;
878
+ }
879
+ export interface NativeNotificationDelivery {
880
+ notificationId: string;
881
+ status: "delivered" | "failed" | "cancelled" | "dropped";
882
+ reason: string;
883
+ }
884
+ export interface NativePhoneNotification {
885
+ /** Stable id from the phone's notification listener; parsed to an int where firmware needs one. */
886
+ notificationId: string;
887
+ /** Reverse-DNS package id of the originating app. */
888
+ packageName: string;
889
+ /** Human app name (e.g. "Messages"). */
890
+ appName: string;
891
+ title: string;
892
+ /** Android `android.subText`. Empty when the listener didn't capture one. */
893
+ subtitle: string;
894
+ body: string;
895
+ /** Unix ms post time. */
896
+ timestampMs: number;
897
+ /** Posted or updated. Removal is not supported by the verified protocol. */
898
+ action: 0;
899
+ }
854
900
  export type PublicGlassesStatus = Omit<GlassesStatus, "otaUpdateAvailable" | "otaProgress" | "otaInProgress" | "otaVersionUrl">;
855
901
  export type PublicBluetoothStatus = Pick<BluetoothStatus, "searching" | "searchingController" | "systemMicUnavailable" | "micRanking" | "currentMic" | "searchResults" | "wifiScanResults" | "lastLog" | "otherBtConnected" | "galleryModeEnabled">;
856
902
  export type BluetoothSdkEventMap = {
903
+ native_notification_status: NativeNotificationStatus;
904
+ native_notification_delivery: NativeNotificationDelivery;
857
905
  log: LogEvent;
858
906
  device_discovered: Device;
859
907
  default_device_changed: {
@@ -912,6 +960,8 @@ export type BluetoothSdkSubscription = {
912
960
  };
913
961
  export type BluetoothSdkEvent = BluetoothSdkEventMap[BluetoothSdkEventName];
914
962
  export interface BluetoothSdkPublicModule {
963
+ configureNativeNotifications(config: NativeNotificationConfig): Promise<void>;
964
+ getNativeNotificationStatus(): Promise<NativeNotificationStatus>;
915
965
  addListener<EventName extends BluetoothSdkEventName>(eventName: EventName, listener: BluetoothSdkEventListener<EventName>): BluetoothSdkSubscription;
916
966
  /** Read an immutable snapshot of the current glasses state. */
917
967
  getGlassesStatus(): Promise<PublicGlassesStatus>;
@@ -1178,6 +1228,11 @@ export interface ConnectOptions {
1178
1228
  cancelExistingConnectionAttempt?: boolean;
1179
1229
  }
1180
1230
  export type ScanResultsCallback = (devices: Device[]) => void;
1231
+ /** Advisory for an empty scan. System connection state does not identify an owning app. */
1232
+ export interface ScanDiagnostic {
1233
+ code: string;
1234
+ message: string;
1235
+ }
1181
1236
  export interface ScanOptions {
1182
1237
  model: DeviceModel;
1183
1238
  /** Defaults to 15000. */
@@ -1186,6 +1241,8 @@ export interface ScanOptions {
1186
1241
  timeout?: number;
1187
1242
  /** Called every time the discovered device list changes during the scan. */
1188
1243
  onResults?: ScanResultsCallback;
1244
+ /** Optional non-fatal hint before an empty scan resolves, on Android and iOS. */
1245
+ onDiagnostic?: (diagnostic: ScanDiagnostic) => void;
1189
1246
  }
1190
1247
  export type ScanModelOptions = Omit<ScanOptions, "model">;
1191
1248
  export interface WifiSearchResult {