@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.
- package/README.md +29 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkModule.kt +21 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/ConnectedDeviceMatcher.kt +69 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/DeviceManager.kt +8 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/GeneratedReleaseMetadata.kt +5 -5
- package/android/src/main/java/com/mentra/bluetoothsdk/MentraBluetoothSdk.kt +66 -1
- package/android/src/main/java/com/mentra/bluetoothsdk/NativeNotifications.kt +42 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/connection/ConnectionModels.kt +38 -1
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/EvenFileService.kt +101 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/EvenNotificationProtocol.kt +113 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/G2.kt +378 -24
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/NotificationIds.kt +19 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/SGCManager.kt +11 -0
- package/android/src/test/java/com/mentra/bluetoothsdk/ConnectedDeviceMatcherTest.kt +161 -0
- package/android/src/test/java/com/mentra/bluetoothsdk/ScanCompletionTest.kt +87 -0
- package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/EvenFileServiceTest.kt +153 -0
- package/build/BluetoothSdk.types.d.ts +57 -0
- package/build/BluetoothSdk.types.d.ts.map +1 -1
- package/build/BluetoothSdk.types.js.map +1 -1
- package/build/_private/BluetoothSdkModule.d.ts +6 -1
- package/build/_private/BluetoothSdkModule.d.ts.map +1 -1
- package/build/_private/BluetoothSdkModule.js +28 -49
- package/build/_private/BluetoothSdkModule.js.map +1 -1
- package/build/_private/scanSession.d.ts +21 -0
- package/build/_private/scanSession.d.ts.map +1 -0
- package/build/_private/scanSession.js +75 -0
- package/build/_private/scanSession.js.map +1 -0
- package/build/generated/releaseMetadata.js +5 -5
- package/build/generated/releaseMetadata.js.map +1 -1
- package/build/index.d.ts +2 -1
- package/build/index.d.ts.map +1 -1
- package/build/index.js +4 -0
- package/build/index.js.map +1 -1
- package/build/react/useBluetoothScan.d.ts +2 -1
- package/build/react/useBluetoothScan.d.ts.map +1 -1
- package/build/react/useBluetoothScan.js +13 -0
- package/build/react/useBluetoothScan.js.map +1 -1
- package/build/react/useMentraBluetooth.d.ts +2 -1
- package/build/react/useMentraBluetooth.d.ts.map +1 -1
- package/build/react/useMentraBluetooth.js +1 -0
- package/build/react/useMentraBluetooth.js.map +1 -1
- package/ios/BluetoothSdkModule.swift +30 -0
- package/ios/Source/BluetoothSdkDefaults.swift +1 -1
- package/ios/Source/GeneratedReleaseMetadata.swift +5 -5
- package/ios/Source/MentraBluetoothSDK.swift +53 -2
- package/ios/Source/NativeNotifications.swift +52 -0
- package/ios/Source/connection/ScanSession.swift +11 -0
- package/ios/Source/internal/BluetoothAvailability.swift +7 -0
- package/ios/Source/internal/ConnectedDeviceMatcher.swift +45 -0
- package/ios/Source/sgcs/G2.swift +131 -21
- package/ios/Source/sgcs/SGCManager.swift +22 -0
- package/ios/Tests/ConnectedDeviceMatcherTests.swift +27 -0
- package/ios/Tests/NativeNotificationTests.swift +20 -0
- package/package.json +1 -1
- package/src/BluetoothSdk.types.ts +62 -0
- package/src/_private/BluetoothSdkModule.ts +47 -57
- package/src/_private/scanSession.ts +88 -0
- package/src/generated/releaseMetadata.ts +5 -5
- package/src/index.ts +7 -0
- package/src/react/useBluetoothScan.ts +12 -1
- package/src/react/useMentraBluetooth.ts +2 -0
package/ios/Source/sgcs/G2.swift
CHANGED
|
@@ -8,8 +8,8 @@
|
|
|
8
8
|
|
|
9
9
|
import Combine
|
|
10
10
|
import CoreBluetooth
|
|
11
|
-
import Foundation
|
|
12
11
|
import CoreGraphics
|
|
12
|
+
import Foundation
|
|
13
13
|
|
|
14
14
|
/// Keep a low-rate, non-audio EvenHub sensor stream active so iOS continues receiving BLE
|
|
15
15
|
/// notifications while the Mentra App is backgrounded. The samples stay internal unless IMU was
|
|
@@ -890,7 +890,7 @@ private enum EvenAIProto {
|
|
|
890
890
|
|
|
891
891
|
// MARK: - Notification Protobuf Builders (notification.proto, service ID 4)
|
|
892
892
|
|
|
893
|
-
|
|
893
|
+
enum G2NotificationProto {
|
|
894
894
|
/// NotificationDataPackage with commandId=NOTIFICATION_IOS (2), carrying
|
|
895
895
|
/// `NotificationIOS { appID, displayName }`. We saw the glasses emit this
|
|
896
896
|
/// inbound after "Hey Even, show notifications" with appID="com.burbn.instagram";
|
|
@@ -1714,8 +1714,13 @@ class G2: NSObject, SGCManager {
|
|
|
1714
1714
|
// The pace replaces the gate's overflow protection (the original reason for gating). Any packet
|
|
1715
1715
|
// CoreBluetooth still drops self-heals: text is re-sent (TextContainer.pendingSends) and image
|
|
1716
1716
|
// fragments retry on their ACK (`awaitImageAck`).
|
|
1717
|
-
private
|
|
1718
|
-
|
|
1717
|
+
private struct PendingWrite {
|
|
1718
|
+
let data: Data
|
|
1719
|
+
let notificationEpoch: Int?
|
|
1720
|
+
}
|
|
1721
|
+
|
|
1722
|
+
private var leftWriteQueue: [PendingWrite] = []
|
|
1723
|
+
private var rightWriteQueue: [PendingWrite] = []
|
|
1719
1724
|
private var leftDraining = false
|
|
1720
1725
|
private var rightDraining = false
|
|
1721
1726
|
// Pace between consecutive packets (~G1's chunk pacing). Off any external callback, so the drain
|
|
@@ -1725,13 +1730,14 @@ class G2: NSObject, SGCManager {
|
|
|
1725
1730
|
// making progress). Rate-limited. Prefixed "BGCAP:" so it's easy to grep/strip after validation.
|
|
1726
1731
|
private var bgcapDepthLogAt: Double = 0
|
|
1727
1732
|
|
|
1728
|
-
private func sendToGlasses(_ packets: [Data], left: Bool = false, right: Bool = true) {
|
|
1733
|
+
private func sendToGlasses(_ packets: [Data], left: Bool = false, right: Bool = true, notificationEpoch: Int? = nil) {
|
|
1734
|
+
let writes = packets.map { PendingWrite(data: $0, notificationEpoch: notificationEpoch) }
|
|
1729
1735
|
if right {
|
|
1730
|
-
rightWriteQueue.append(contentsOf:
|
|
1736
|
+
rightWriteQueue.append(contentsOf: writes)
|
|
1731
1737
|
startDrain(right: true)
|
|
1732
1738
|
}
|
|
1733
1739
|
if left {
|
|
1734
|
-
leftWriteQueue.append(contentsOf:
|
|
1740
|
+
leftWriteQueue.append(contentsOf: writes)
|
|
1735
1741
|
startDrain(right: false)
|
|
1736
1742
|
}
|
|
1737
1743
|
}
|
|
@@ -1777,7 +1783,8 @@ class G2: NSObject, SGCManager {
|
|
|
1777
1783
|
}
|
|
1778
1784
|
}
|
|
1779
1785
|
let packet = right ? rightWriteQueue.removeFirst() : leftWriteQueue.removeFirst()
|
|
1780
|
-
|
|
1786
|
+
if let epoch = packet.notificationEpoch, epoch != notificationEpoch { continue }
|
|
1787
|
+
peripheral.writeValue(packet.data, for: char, type: .withoutResponse)
|
|
1781
1788
|
try? await Task.sleep(nanoseconds: writePaceNanos)
|
|
1782
1789
|
}
|
|
1783
1790
|
}
|
|
@@ -2659,7 +2666,7 @@ class G2: NSObject, SGCManager {
|
|
|
2659
2666
|
// "G2: sendImageData(\(containerName)) - \(fragmentCount) fragments, \(bmpData.count) bytes"
|
|
2660
2667
|
// )
|
|
2661
2668
|
|
|
2662
|
-
for
|
|
2669
|
+
for _ in 1 ... IMG_MAX_ATTEMPTS {
|
|
2663
2670
|
// One session id per WHOLE image transfer (per attempt). The glasses key their
|
|
2664
2671
|
// reassembly buffer on MapSessionId, so every fragment of this image must reuse the
|
|
2665
2672
|
// same session id with an incrementing MapFragmentIndex; the per-fragment ACK is
|
|
@@ -3659,6 +3666,8 @@ class G2: NSObject, SGCManager {
|
|
|
3659
3666
|
func disconnect() {
|
|
3660
3667
|
Bridge.log("G2: disconnect()")
|
|
3661
3668
|
isDisconnecting = true
|
|
3669
|
+
notificationEpoch += 1
|
|
3670
|
+
notificationControlMagic = nil
|
|
3662
3671
|
clearDisplay()
|
|
3663
3672
|
cancelPairingTimeout()
|
|
3664
3673
|
stopHeartbeats()
|
|
@@ -3807,6 +3816,88 @@ class G2: NSObject, SGCManager {
|
|
|
3807
3816
|
sendEvenAICommand(payload)
|
|
3808
3817
|
}
|
|
3809
3818
|
|
|
3819
|
+
private var notificationConfig = NativeNotificationConfig()
|
|
3820
|
+
private var notificationEpoch = 0
|
|
3821
|
+
private var notificationControlMagic: Int32?
|
|
3822
|
+
private var notificationError = ""
|
|
3823
|
+
|
|
3824
|
+
private var notificationAuthorization: String {
|
|
3825
|
+
#if os(macOS)
|
|
3826
|
+
return "unknown"
|
|
3827
|
+
#else
|
|
3828
|
+
let connected = [leftPeripheral, rightPeripheral].compactMap { $0 }.filter { $0.state == .connected }
|
|
3829
|
+
guard !connected.isEmpty else { return "unknown" }
|
|
3830
|
+
return connected.contains { $0.ancsAuthorized } ? "authorized" : "not_authorized"
|
|
3831
|
+
#endif
|
|
3832
|
+
}
|
|
3833
|
+
|
|
3834
|
+
func getNativeNotificationStatus() -> NativeNotificationStatus {
|
|
3835
|
+
#if os(macOS)
|
|
3836
|
+
return .unavailable
|
|
3837
|
+
#else
|
|
3838
|
+
let ready = DeviceStore.shared.get("glasses", "fullyBooted") as? Bool ?? false
|
|
3839
|
+
return NativeNotificationStatus(
|
|
3840
|
+
supported: true, source: "ancs", authorization: notificationAuthorization,
|
|
3841
|
+
state: !ready ? "unavailable" : !notificationError.isEmpty ? "failed" : notificationConfig.enabled ? "submitted" : "disabled",
|
|
3842
|
+
config: notificationConfig, error: notificationError
|
|
3843
|
+
)
|
|
3844
|
+
#endif
|
|
3845
|
+
}
|
|
3846
|
+
|
|
3847
|
+
private func publishNotificationStatus() {
|
|
3848
|
+
Bridge.sendTypedMessage("native_notification_status", body: getNativeNotificationStatus().dictionary)
|
|
3849
|
+
}
|
|
3850
|
+
|
|
3851
|
+
func configureNativeNotifications(_ config: NativeNotificationConfig) throws {
|
|
3852
|
+
#if os(macOS)
|
|
3853
|
+
throw NativeNotificationError.unsupported
|
|
3854
|
+
#else
|
|
3855
|
+
try config.validateForAncs()
|
|
3856
|
+
notificationConfig = config
|
|
3857
|
+
applyNotificationControls()
|
|
3858
|
+
#endif
|
|
3859
|
+
}
|
|
3860
|
+
|
|
3861
|
+
private func applyNotificationControls() {
|
|
3862
|
+
#if os(macOS)
|
|
3863
|
+
publishNotificationStatus()
|
|
3864
|
+
return
|
|
3865
|
+
#endif
|
|
3866
|
+
notificationControlMagic = nil
|
|
3867
|
+
notificationEpoch += 1 // Discard unsent controls from the previous settings/connection.
|
|
3868
|
+
notificationError = ""
|
|
3869
|
+
guard DeviceStore.shared.get("glasses", "fullyBooted") as? Bool == true else {
|
|
3870
|
+
publishNotificationStatus()
|
|
3871
|
+
return
|
|
3872
|
+
}
|
|
3873
|
+
let config = notificationConfig
|
|
3874
|
+
// Never enable ANCS presentation before the accessory is authorized. Disabling
|
|
3875
|
+
// still reaches firmware after permission revocation.
|
|
3876
|
+
let enabled = config.enabled && notificationAuthorization == "authorized"
|
|
3877
|
+
let magic = sendManager.nextMagicRandom()
|
|
3878
|
+
notificationControlMagic = magic
|
|
3879
|
+
let payload = G2NotificationProto.notificationCtrl(
|
|
3880
|
+
magicRandom: magic, notifEnable: enabled ? 1 : 0,
|
|
3881
|
+
autoDispEnable: config.autoDisplay ? 1 : 0,
|
|
3882
|
+
dispTime: Int32(config.durationSeconds), avoidDisturbEnable: config.doNotDisturb ? 1 : 0
|
|
3883
|
+
)
|
|
3884
|
+
sendToGlasses(sendManager.buildPackets(serviceId: ServiceID.notification.rawValue, payload: payload, reserveFlag: true), notificationEpoch: notificationEpoch)
|
|
3885
|
+
// Preserve the firmware's existing iOS app whitelist. Android package filters
|
|
3886
|
+
// cannot safely be applied to the ANCS stream or translated to bundle IDs.
|
|
3887
|
+
publishNotificationStatus()
|
|
3888
|
+
}
|
|
3889
|
+
|
|
3890
|
+
private var notificationConnectionOptions: [String: Any] {
|
|
3891
|
+
var options: [String: Any] = [
|
|
3892
|
+
CBConnectPeripheralOptionNotifyOnConnectionKey: true,
|
|
3893
|
+
CBConnectPeripheralOptionNotifyOnDisconnectionKey: true,
|
|
3894
|
+
]
|
|
3895
|
+
#if !os(macOS)
|
|
3896
|
+
options[CBConnectPeripheralOptionRequiresANCS] = true
|
|
3897
|
+
#endif
|
|
3898
|
+
return options
|
|
3899
|
+
}
|
|
3900
|
+
|
|
3810
3901
|
/// Open the on-glasses notification panel — same effect as the user saying
|
|
3811
3902
|
/// "Hey Even, show notifications". Replicates the official-app voice flow:
|
|
3812
3903
|
/// 1. CTRL{status=ENTER} — puts glasses in AI session
|
|
@@ -4102,10 +4193,7 @@ class G2: NSObject, SGCManager {
|
|
|
4102
4193
|
device.discoverServices([G2BLE.SERVICE_UUID])
|
|
4103
4194
|
centralManager!.connect(
|
|
4104
4195
|
leftPeripheral!,
|
|
4105
|
-
options:
|
|
4106
|
-
CBConnectPeripheralOptionNotifyOnConnectionKey: true,
|
|
4107
|
-
CBConnectPeripheralOptionNotifyOnDisconnectionKey: true,
|
|
4108
|
-
]
|
|
4196
|
+
options: notificationConnectionOptions
|
|
4109
4197
|
)
|
|
4110
4198
|
} else if name.contains("_R_") && serialNumber.contains(DEVICE_SEARCH_ID) {
|
|
4111
4199
|
rightPeripheral = device
|
|
@@ -4113,10 +4201,7 @@ class G2: NSObject, SGCManager {
|
|
|
4113
4201
|
device.discoverServices([G2BLE.SERVICE_UUID])
|
|
4114
4202
|
centralManager!.connect(
|
|
4115
4203
|
rightPeripheral!,
|
|
4116
|
-
options:
|
|
4117
|
-
CBConnectPeripheralOptionNotifyOnConnectionKey: true,
|
|
4118
|
-
CBConnectPeripheralOptionNotifyOnDisconnectionKey: true,
|
|
4119
|
-
]
|
|
4204
|
+
options: notificationConnectionOptions
|
|
4120
4205
|
)
|
|
4121
4206
|
}
|
|
4122
4207
|
// we can't emit the serial number here unfortunately:
|
|
@@ -4180,8 +4265,8 @@ class G2: NSObject, SGCManager {
|
|
|
4180
4265
|
rightPeripheral = right
|
|
4181
4266
|
left.delegate = self
|
|
4182
4267
|
right.delegate = self
|
|
4183
|
-
centralManager?.connect(left, options:
|
|
4184
|
-
centralManager?.connect(right, options:
|
|
4268
|
+
centralManager?.connect(left, options: notificationConnectionOptions)
|
|
4269
|
+
centralManager?.connect(right, options: notificationConnectionOptions)
|
|
4185
4270
|
return true
|
|
4186
4271
|
}
|
|
4187
4272
|
|
|
@@ -4231,6 +4316,7 @@ class G2: NSObject, SGCManager {
|
|
|
4231
4316
|
// MARK: - Incoming Data Handling
|
|
4232
4317
|
|
|
4233
4318
|
private func handleNotifyData(_ data: Data, from peripheral: CBPeripheral) async {
|
|
4319
|
+
guard peripheral === leftPeripheral || peripheral === rightPeripheral else { return }
|
|
4234
4320
|
// Distinguish left vs right peripheral so multi-packet reassembly doesn't collide
|
|
4235
4321
|
let sourceKey = peripheral === leftPeripheral ? "L" : "R"
|
|
4236
4322
|
guard let result = receiveManager.handlePacket(data, sourceKey: sourceKey) else { return }
|
|
@@ -4286,6 +4372,16 @@ class G2: NSObject, SGCManager {
|
|
|
4286
4372
|
let fields = reader.parseFields()
|
|
4287
4373
|
let cmd = fields[1] as? Int32 ?? 0
|
|
4288
4374
|
|
|
4375
|
+
if cmd == 161, let response = fields[5] as? Data {
|
|
4376
|
+
var responseReader = ProtobufReader(response)
|
|
4377
|
+
let values = responseReader.parseFields()
|
|
4378
|
+
if fields[2] as? Int32 == notificationControlMagic,
|
|
4379
|
+
values[1] as? Int32 == 1, let code = values[2] as? Int32, code != 0
|
|
4380
|
+
{
|
|
4381
|
+
notificationError = "configuration_rejected_\(values[2] as? Int32 ?? -1)"
|
|
4382
|
+
publishNotificationStatus()
|
|
4383
|
+
}
|
|
4384
|
+
}
|
|
4289
4385
|
var detail = ""
|
|
4290
4386
|
if let iosData = fields[4] as? Data {
|
|
4291
4387
|
var ios = ProtobufReader(iosData)
|
|
@@ -4525,6 +4621,7 @@ class G2: NSObject, SGCManager {
|
|
|
4525
4621
|
}
|
|
4526
4622
|
if !isFullyBooted {
|
|
4527
4623
|
DeviceStore.shared.apply("glasses", "fullyBooted", true)
|
|
4624
|
+
applyNotificationControls()
|
|
4528
4625
|
}
|
|
4529
4626
|
}
|
|
4530
4627
|
|
|
@@ -5157,14 +5254,14 @@ extension G2: CBCentralManagerDelegate {
|
|
|
5157
5254
|
if self.leftPeripheral == nil {
|
|
5158
5255
|
self.leftPeripheral = peripheral
|
|
5159
5256
|
peripheral.delegate = self
|
|
5160
|
-
central.connect(peripheral, options:
|
|
5257
|
+
central.connect(peripheral, options: self.notificationConnectionOptions)
|
|
5161
5258
|
// Bridge.log("G2: Connecting to LEFT: \(name)")
|
|
5162
5259
|
}
|
|
5163
5260
|
} else if name.contains("_R_") {
|
|
5164
5261
|
if self.rightPeripheral == nil {
|
|
5165
5262
|
self.rightPeripheral = peripheral
|
|
5166
5263
|
peripheral.delegate = self
|
|
5167
|
-
central.connect(peripheral, options:
|
|
5264
|
+
central.connect(peripheral, options: self.notificationConnectionOptions)
|
|
5168
5265
|
// Bridge.log("G2: Connecting to RIGHT: \(name)")
|
|
5169
5266
|
}
|
|
5170
5267
|
}
|
|
@@ -5177,6 +5274,15 @@ extension G2: CBCentralManagerDelegate {
|
|
|
5177
5274
|
}
|
|
5178
5275
|
}
|
|
5179
5276
|
|
|
5277
|
+
#if !os(macOS)
|
|
5278
|
+
nonisolated func centralManager(_: CBCentralManager, didUpdateANCSAuthorizationFor peripheral: CBPeripheral) {
|
|
5279
|
+
DispatchQueue.main.async { [weak self] in
|
|
5280
|
+
guard let self, peripheral === self.leftPeripheral || peripheral === self.rightPeripheral else { return }
|
|
5281
|
+
self.applyNotificationControls()
|
|
5282
|
+
}
|
|
5283
|
+
}
|
|
5284
|
+
#endif
|
|
5285
|
+
|
|
5180
5286
|
nonisolated func centralManager(_: CBCentralManager, didConnect peripheral: CBPeripheral) {
|
|
5181
5287
|
DispatchQueue.main.async { [weak self] in
|
|
5182
5288
|
guard let self = self else { return }
|
|
@@ -5209,6 +5315,9 @@ extension G2: CBCentralManagerDelegate {
|
|
|
5209
5315
|
let side = peripheral === self.leftPeripheral ? "LEFT" : "RIGHT"
|
|
5210
5316
|
Bridge.log("G2: Disconnected \(side): \(error?.localizedDescription ?? "clean")")
|
|
5211
5317
|
|
|
5318
|
+
guard peripheral === self.leftPeripheral || peripheral === self.rightPeripheral else { return }
|
|
5319
|
+
self.notificationEpoch += 1
|
|
5320
|
+
self.notificationControlMagic = nil
|
|
5212
5321
|
// Only reconnect if not intentionally disconnecting
|
|
5213
5322
|
if self.isDisconnecting { return }
|
|
5214
5323
|
|
|
@@ -5233,6 +5342,7 @@ extension G2: CBCentralManagerDelegate {
|
|
|
5233
5342
|
self.dashboardOpening = false
|
|
5234
5343
|
DeviceStore.shared.apply("glasses", "connected", false)
|
|
5235
5344
|
DeviceStore.shared.apply("glasses", "fullyBooted", false)
|
|
5345
|
+
self.publishNotificationStatus()
|
|
5236
5346
|
|
|
5237
5347
|
// Start persistent reconnection loop (every 30s, unlimited attempts)
|
|
5238
5348
|
self.startReconnectionTimer()
|
|
@@ -145,6 +145,14 @@ protocol SGCManager {
|
|
|
145
145
|
|
|
146
146
|
func showNotificationsPanel() async
|
|
147
147
|
|
|
148
|
+
/// Push a phone notification into the glasses' own notification centre.
|
|
149
|
+
///
|
|
150
|
+
/// Android/G2 only in practice — iOS glasses read notifications over ANCS. Declared for
|
|
151
|
+
/// parity so the shared TypeScript module type is callable on both platforms.
|
|
152
|
+
func sendPhoneNotification(_ notification: [String: Any]) throws
|
|
153
|
+
func configureNativeNotifications(_ config: NativeNotificationConfig) throws
|
|
154
|
+
func getNativeNotificationStatus() -> NativeNotificationStatus
|
|
155
|
+
|
|
148
156
|
// MARK: - Calendar Events
|
|
149
157
|
|
|
150
158
|
func sendCalendarEvents(_ events: [[String: Any]])
|
|
@@ -357,6 +365,20 @@ extension SGCManager {
|
|
|
357
365
|
|
|
358
366
|
func showNotificationsPanel() async {}
|
|
359
367
|
|
|
368
|
+
// MARK: - Native notification centre (default no-op — Android/G2 only; iOS uses ANCS)
|
|
369
|
+
|
|
370
|
+
func sendPhoneNotification(_: [String: Any]) throws {
|
|
371
|
+
throw NativeNotificationError.unsupported
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
func configureNativeNotifications(_: NativeNotificationConfig) throws {
|
|
375
|
+
throw NativeNotificationError.unsupported
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
func getNativeNotificationStatus() -> NativeNotificationStatus {
|
|
379
|
+
.unavailable
|
|
380
|
+
}
|
|
381
|
+
|
|
360
382
|
// MARK: - IMU (default no-op — only G2 streams accelerometer data)
|
|
361
383
|
|
|
362
384
|
func setImuEnabled(_: Bool) async {
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
@testable import MentraBluetoothSDK
|
|
3
|
+
import XCTest
|
|
4
|
+
|
|
5
|
+
final class ConnectedDeviceMatcherTests: XCTestCase {
|
|
6
|
+
func testSavedIdentityTakesPrecedenceOverName() {
|
|
7
|
+
let saved = Device(model: .g1, name: "Saved glasses", identifier: "ABC")
|
|
8
|
+
XCTAssertTrue(ConnectedDeviceMatcher.matches(model: .g1, defaultDevice: saved, name: nil, identifier: "abc"))
|
|
9
|
+
XCTAssertFalse(ConnectedDeviceMatcher.matches(model: .g1, defaultDevice: saved, name: saved.name, identifier: "OTHER"))
|
|
10
|
+
XCTAssertTrue(ConnectedDeviceMatcher.matches(model: .g1, defaultDevice: saved, name: saved.name, identifier: nil))
|
|
11
|
+
XCTAssertFalse(ConnectedDeviceMatcher.matches(model: .g2, defaultDevice: saved, name: saved.name, identifier: "ABC"))
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
func testKnownNamesAreOnlyHintsForTheirModel() {
|
|
15
|
+
XCTAssertTrue(ConnectedDeviceMatcher.matches(model: .mentraLive, defaultDevice: nil, name: "MENTRA_LIVE_BLE_123", identifier: nil))
|
|
16
|
+
XCTAssertTrue(ConnectedDeviceMatcher.matches(model: .mentraNex, defaultDevice: nil, name: "Nex1-123", identifier: nil))
|
|
17
|
+
XCTAssertFalse(ConnectedDeviceMatcher.matches(model: .g1, defaultDevice: nil, name: "MENTRA_LIVE_BLE_123", identifier: nil))
|
|
18
|
+
XCTAssertFalse(ConnectedDeviceMatcher.matches(model: .simulated, defaultDevice: nil, name: "Nex1-123", identifier: nil))
|
|
19
|
+
XCTAssertFalse(ConnectedDeviceMatcher.matches(model: .mentraLive, defaultDevice: nil, name: "Headphones", identifier: nil))
|
|
20
|
+
XCTAssertFalse(ConnectedDeviceMatcher.matches(model: .g1, defaultDevice: Device(model: .g1, name: ""), name: "", identifier: ""))
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
func testUnsupportedModelsDoNotQueryCoreBluetooth() {
|
|
24
|
+
XCTAssertTrue(ConnectedDeviceMatcher.serviceUUIDs(for: .simulated).isEmpty)
|
|
25
|
+
XCTAssertFalse(ConnectedDeviceMatcher.serviceUUIDs(for: .mentraLive).isEmpty)
|
|
26
|
+
}
|
|
27
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
@testable import MentraBluetoothSDK
|
|
2
|
+
import XCTest
|
|
3
|
+
|
|
4
|
+
final class NativeNotificationTests: XCTestCase {
|
|
5
|
+
func testDisableIsExplicitAndQuietModeDoesNotEnablePopups() {
|
|
6
|
+
let bytes = G2NotificationProto.notificationCtrl(magicRandom: 7, notifEnable: 0, autoDispEnable: 0, dispTime: 10, avoidDisturbEnable: 1)
|
|
7
|
+
XCTAssertEqual(bytes, Data([0x08, 1, 0x10, 7, 0x1A, 8, 0x08, 0, 0x10, 0, 0x18, 10, 0x28, 1]))
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
func testConfigRejectsInvalidDurationBeforeEncoding() {
|
|
11
|
+
XCTAssertThrowsError(try NativeNotificationConfig(durationSeconds: 0).validate())
|
|
12
|
+
XCTAssertThrowsError(try NativeNotificationConfig(durationSeconds: 31).validate())
|
|
13
|
+
XCTAssertNoThrow(try NativeNotificationConfig(durationSeconds: 30).validate())
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
func testAncsRejectsPhoneSideFiltering() {
|
|
17
|
+
XCTAssertThrowsError(try NativeNotificationConfig(blockedApps: ["com.example"]).validateForAncs())
|
|
18
|
+
XCTAssertNoThrow(try NativeNotificationConfig(blockedApps: []).validateForAncs())
|
|
19
|
+
}
|
|
20
|
+
}
|
package/package.json
CHANGED
|
@@ -924,6 +924,8 @@ export type BluetoothSdkModuleEvents = {
|
|
|
924
924
|
speaking_status: (event: SpeakingStatusEvent) => void
|
|
925
925
|
battery_status: (event: BatteryStatusEvent) => void
|
|
926
926
|
local_transcription: (event: LocalTranscriptionEvent) => void
|
|
927
|
+
native_notification_status: (event: NativeNotificationStatus) => void
|
|
928
|
+
native_notification_delivery: (event: NativeNotificationDelivery) => void
|
|
927
929
|
phone_notification: (event: PhoneNotificationEvent) => void
|
|
928
930
|
phone_notification_dismissed: (event: PhoneNotificationDismissedEvent) => void
|
|
929
931
|
wifi_status_change: (event: WifiStatusChangeEvent) => void
|
|
@@ -1004,6 +1006,54 @@ export interface PhoneNotificationDismissedEvent {
|
|
|
1004
1006
|
timestamp: number
|
|
1005
1007
|
}
|
|
1006
1008
|
|
|
1009
|
+
/**
|
|
1010
|
+
* Outbound payload for `sendPhoneNotification` — a notification pushed INTO the glasses' own
|
|
1011
|
+
* notification centre. The inverse of {@link PhoneNotificationEvent}, which reports
|
|
1012
|
+
* notifications the glasses relayed TO the phone (iOS/ANCS). Drivers map these keys onto
|
|
1013
|
+
* whatever their firmware expects.
|
|
1014
|
+
*/
|
|
1015
|
+
export interface NativeNotificationConfig {
|
|
1016
|
+
enabled: boolean
|
|
1017
|
+
autoDisplay: boolean
|
|
1018
|
+
durationSeconds: number
|
|
1019
|
+
doNotDisturb: boolean
|
|
1020
|
+
/** Android package names. iOS rejects nonempty lists; its ANCS filter is firmware-owned. */
|
|
1021
|
+
blockedApps: string[]
|
|
1022
|
+
}
|
|
1023
|
+
|
|
1024
|
+
export interface NativeNotificationStatus {
|
|
1025
|
+
supported: boolean
|
|
1026
|
+
source: "phone" | "ancs" | "unsupported"
|
|
1027
|
+
authorization: "system" | "authorized" | "not_authorized" | "unknown"
|
|
1028
|
+
/** `submitted` is not a firmware-confirmed acknowledgement. */
|
|
1029
|
+
state: "unavailable" | "disabled" | "configuring" | "submitted" | "needs_reconnect" | "failed"
|
|
1030
|
+
config: NativeNotificationConfig
|
|
1031
|
+
error: string
|
|
1032
|
+
}
|
|
1033
|
+
|
|
1034
|
+
export interface NativeNotificationDelivery {
|
|
1035
|
+
notificationId: string
|
|
1036
|
+
status: "delivered" | "failed" | "cancelled" | "dropped"
|
|
1037
|
+
reason: string
|
|
1038
|
+
}
|
|
1039
|
+
|
|
1040
|
+
export interface NativePhoneNotification {
|
|
1041
|
+
/** Stable id from the phone's notification listener; parsed to an int where firmware needs one. */
|
|
1042
|
+
notificationId: string
|
|
1043
|
+
/** Reverse-DNS package id of the originating app. */
|
|
1044
|
+
packageName: string
|
|
1045
|
+
/** Human app name (e.g. "Messages"). */
|
|
1046
|
+
appName: string
|
|
1047
|
+
title: string
|
|
1048
|
+
/** Android `android.subText`. Empty when the listener didn't capture one. */
|
|
1049
|
+
subtitle: string
|
|
1050
|
+
body: string
|
|
1051
|
+
/** Unix ms post time. */
|
|
1052
|
+
timestampMs: number
|
|
1053
|
+
/** Posted or updated. Removal is not supported by the verified protocol. */
|
|
1054
|
+
action: 0
|
|
1055
|
+
}
|
|
1056
|
+
|
|
1007
1057
|
export type PublicGlassesStatus = Omit<
|
|
1008
1058
|
GlassesStatus,
|
|
1009
1059
|
"otaUpdateAvailable" | "otaProgress" | "otaInProgress" | "otaVersionUrl"
|
|
@@ -1024,6 +1074,8 @@ export type PublicBluetoothStatus = Pick<
|
|
|
1024
1074
|
>
|
|
1025
1075
|
|
|
1026
1076
|
export type BluetoothSdkEventMap = {
|
|
1077
|
+
native_notification_status: NativeNotificationStatus
|
|
1078
|
+
native_notification_delivery: NativeNotificationDelivery
|
|
1027
1079
|
log: LogEvent
|
|
1028
1080
|
device_discovered: Device
|
|
1029
1081
|
default_device_changed: {device?: Device}
|
|
@@ -1087,6 +1139,8 @@ export type BluetoothSdkSubscription = {
|
|
|
1087
1139
|
export type BluetoothSdkEvent = BluetoothSdkEventMap[BluetoothSdkEventName]
|
|
1088
1140
|
|
|
1089
1141
|
export interface BluetoothSdkPublicModule {
|
|
1142
|
+
configureNativeNotifications(config: NativeNotificationConfig): Promise<void>
|
|
1143
|
+
getNativeNotificationStatus(): Promise<NativeNotificationStatus>
|
|
1090
1144
|
addListener<EventName extends BluetoothSdkEventName>(
|
|
1091
1145
|
eventName: EventName,
|
|
1092
1146
|
listener: BluetoothSdkEventListener<EventName>,
|
|
@@ -1435,6 +1489,12 @@ export interface ConnectOptions {
|
|
|
1435
1489
|
|
|
1436
1490
|
export type ScanResultsCallback = (devices: Device[]) => void
|
|
1437
1491
|
|
|
1492
|
+
/** Advisory for an empty scan. System connection state does not identify an owning app. */
|
|
1493
|
+
export interface ScanDiagnostic {
|
|
1494
|
+
code: string
|
|
1495
|
+
message: string
|
|
1496
|
+
}
|
|
1497
|
+
|
|
1438
1498
|
export interface ScanOptions {
|
|
1439
1499
|
model: DeviceModel
|
|
1440
1500
|
/** Defaults to 15000. */
|
|
@@ -1443,6 +1503,8 @@ export interface ScanOptions {
|
|
|
1443
1503
|
timeout?: number
|
|
1444
1504
|
/** Called every time the discovered device list changes during the scan. */
|
|
1445
1505
|
onResults?: ScanResultsCallback
|
|
1506
|
+
/** Optional non-fatal hint before an empty scan resolves, on Android and iOS. */
|
|
1507
|
+
onDiagnostic?: (diagnostic: ScanDiagnostic) => void
|
|
1446
1508
|
}
|
|
1447
1509
|
|
|
1448
1510
|
export type ScanModelOptions = Omit<ScanOptions, "model">
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import {createScanSession} from "./scanSession"
|
|
1
2
|
import {NativeModule, requireNativeModule} from "expo"
|
|
2
3
|
|
|
3
4
|
import {
|
|
@@ -26,6 +27,9 @@ import {
|
|
|
26
27
|
GalleryStatusEvent,
|
|
27
28
|
HotspotStatusChangeEvent,
|
|
28
29
|
MicPreference,
|
|
30
|
+
NativePhoneNotification,
|
|
31
|
+
NativeNotificationConfig,
|
|
32
|
+
NativeNotificationStatus,
|
|
29
33
|
ObservableStoreCategory,
|
|
30
34
|
OtaQueryResult,
|
|
31
35
|
OtaStartAckEvent,
|
|
@@ -35,6 +39,7 @@ import {
|
|
|
35
39
|
RgbLedAction,
|
|
36
40
|
RgbLedColor,
|
|
37
41
|
RgbLedControlSuccessResponseEvent,
|
|
42
|
+
ScanDiagnostic,
|
|
38
43
|
ScanModelOptions,
|
|
39
44
|
ScanOptions,
|
|
40
45
|
SettingsAckSuccessEvent,
|
|
@@ -85,6 +90,7 @@ declare class BluetoothSdkNativeModule extends NativeModule<BluetoothSdkModuleEv
|
|
|
85
90
|
connectDefaultWithOptions(options: Required<ConnectOptions>): Promise<void>
|
|
86
91
|
setDefaultDevice(device: Device | null): Promise<void>
|
|
87
92
|
clearDefaultDevice(): Promise<void>
|
|
93
|
+
getScanDiagnostic?: (model: DeviceModel) => Promise<ScanDiagnostic | null>
|
|
88
94
|
startScan(model: DeviceModel): Promise<void>
|
|
89
95
|
stopScan(): Promise<void>
|
|
90
96
|
scan(options: ScanOptions): Promise<Device[]>
|
|
@@ -166,7 +172,13 @@ declare class BluetoothSdkNativeModule extends NativeModule<BluetoothSdkModuleEv
|
|
|
166
172
|
startAr99OtaFromFile(path: string): Promise<boolean>
|
|
167
173
|
cancelAr99Ota(): Promise<void>
|
|
168
174
|
sendAr99FactoryReset(): Promise<void>
|
|
169
|
-
buildAr99OtaSignature(
|
|
175
|
+
buildAr99OtaSignature(
|
|
176
|
+
secret: string,
|
|
177
|
+
appName: string,
|
|
178
|
+
currentVersion: string,
|
|
179
|
+
serialNumber: string,
|
|
180
|
+
nonce: string,
|
|
181
|
+
): string
|
|
170
182
|
|
|
171
183
|
// Version Info Commands
|
|
172
184
|
requestVersionInfo(): Promise<VersionInfoResult>
|
|
@@ -255,6 +267,11 @@ declare class BluetoothSdkNativeModule extends NativeModule<BluetoothSdkModuleEv
|
|
|
255
267
|
speed: number,
|
|
256
268
|
): Promise<boolean>
|
|
257
269
|
|
|
270
|
+
/** Android G2 content upload. iOS receives ANCS directly and rejects uploads. */
|
|
271
|
+
sendPhoneNotification(notification: NativePhoneNotification): Promise<void>
|
|
272
|
+
configureNativeNotifications(config: NativeNotificationConfig): Promise<void>
|
|
273
|
+
getNativeNotificationStatus(): Promise<NativeNotificationStatus>
|
|
274
|
+
|
|
258
275
|
// Helper methods for type-safe observable store access
|
|
259
276
|
updateGlasses(values: Partial<GlassesStatus>): Promise<void>
|
|
260
277
|
updateBluetoothSettings(values: BluetoothSettingsUpdate): Promise<void>
|
|
@@ -581,65 +598,38 @@ NativeBluetoothSdkModule.connect = function (device: Device, options?: ConnectOp
|
|
|
581
598
|
return this.connectWithOptions(device, {...DEFAULT_CONNECT_OPTIONS, ...options})
|
|
582
599
|
}
|
|
583
600
|
|
|
601
|
+
const nativeStopScan = NativeBluetoothSdkModule.stopScan.bind(NativeBluetoothSdkModule)
|
|
602
|
+
const activeScanCancellations = new Set<() => Promise<void>>()
|
|
603
|
+
NativeBluetoothSdkModule.stopScan = async function () {
|
|
604
|
+
if (activeScanCancellations.size === 0) return nativeStopScan()
|
|
605
|
+
await Promise.all([...activeScanCancellations].map(cancel => cancel()))
|
|
606
|
+
}
|
|
607
|
+
|
|
584
608
|
NativeBluetoothSdkModule.scan = async function (modelOrOptions: DeviceModel | ScanOptions, options?: ScanModelOptions) {
|
|
609
|
+
if (activeScanCancellations.size > 0) await this.stopScan()
|
|
585
610
|
const scanOptions = normalizeScanArgs(modelOrOptions, options)
|
|
586
611
|
const timeoutMs = normalizeTimeoutMs(scanOptions.timeoutMs ?? scanOptions.timeout, DEFAULT_SCAN_TIMEOUT_MS)
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
}
|
|
609
|
-
|
|
610
|
-
const settle = (error?: Error) => {
|
|
611
|
-
if (settled) {
|
|
612
|
-
return
|
|
613
|
-
}
|
|
614
|
-
settled = true
|
|
615
|
-
cleanup()
|
|
616
|
-
if (error) {
|
|
617
|
-
reject(error)
|
|
618
|
-
} else {
|
|
619
|
-
resolve([...latestResults])
|
|
620
|
-
}
|
|
621
|
-
}
|
|
622
|
-
|
|
623
|
-
const handleBluetoothStatus = (status: Partial<BluetoothStatus>) => {
|
|
624
|
-
if (!Array.isArray(status.searchResults)) {
|
|
625
|
-
return
|
|
626
|
-
}
|
|
627
|
-
emitResults(searchResultsForModel(status, scanOptions.model))
|
|
628
|
-
}
|
|
629
|
-
|
|
630
|
-
removeBluetoothListener = this.onBluetoothStatus(handleBluetoothStatus)
|
|
631
|
-
emitResults([])
|
|
632
|
-
|
|
633
|
-
timeout = setTimeout(() => settle(), timeoutMs)
|
|
634
|
-
|
|
635
|
-
Promise.resolve(this.startScan(scanOptions.model))
|
|
636
|
-
.then(() => {
|
|
637
|
-
scanStarted = true
|
|
638
|
-
return this.getBluetoothStatus()
|
|
639
|
-
})
|
|
640
|
-
.then(handleBluetoothStatus)
|
|
641
|
-
.catch((error) => settle(error instanceof Error ? error : new Error(String(error))))
|
|
642
|
-
})
|
|
612
|
+
const session = createScanSession(
|
|
613
|
+
{
|
|
614
|
+
start: (model) => this.startScan(model),
|
|
615
|
+
stop: nativeStopScan,
|
|
616
|
+
subscribe: (onResults) =>
|
|
617
|
+
this.onBluetoothStatus((status) => {
|
|
618
|
+
if (Array.isArray(status.searchResults)) onResults(searchResultsForModel(status, scanOptions.model))
|
|
619
|
+
}),
|
|
620
|
+
getResults: async () => searchResultsForModel(await this.getBluetoothStatus(), scanOptions.model),
|
|
621
|
+
// Older native binaries simply omit diagnostics until the host is rebuilt.
|
|
622
|
+
getDiagnostic:
|
|
623
|
+
typeof this.getScanDiagnostic === "function" ? () => this.getScanDiagnostic!(scanOptions.model) : undefined,
|
|
624
|
+
},
|
|
625
|
+
{...scanOptions, timeoutMs},
|
|
626
|
+
)
|
|
627
|
+
activeScanCancellations.add(session.cancel)
|
|
628
|
+
try {
|
|
629
|
+
return await session.result
|
|
630
|
+
} finally {
|
|
631
|
+
activeScanCancellations.delete(session.cancel)
|
|
632
|
+
}
|
|
643
633
|
}
|
|
644
634
|
|
|
645
635
|
const nativeRequestPhoto = NativeBluetoothSdkModule.requestPhoto.bind(NativeBluetoothSdkModule)
|