@mentra/bluetooth-sdk 3.3.0-dev.517 → 3.3.0-dev.523
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/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkModule.kt +2 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/DeviceManager.kt +55 -10
- package/android/src/main/java/com/mentra/bluetoothsdk/GeneratedReleaseMetadata.kt +5 -5
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/Nimo.kt +63 -6
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/NimoCanvasCoordinator.kt +26 -13
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/NimoCanvasSession.kt +31 -19
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/SerializedGattCallback.kt +5 -0
- package/android/src/test/java/com/mentra/bluetoothsdk/DeviceManagerSceneHandoffTest.kt +111 -2
- package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/MentraLiveGattCallbackTest.kt +33 -0
- package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/NimoCanvasCoordinatorTest.kt +44 -4
- package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/NimoCanvasSessionTest.kt +82 -13
- package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/NimoMicReconnectTest.kt +93 -0
- package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/NimoProtocolTest.kt +11 -0
- package/android/src/testDebug/java/com/mentra/bluetoothsdk/sgcs/NimoLifecycleTest.kt +94 -0
- package/android/src/testDebug/java/com/mentra/bluetoothsdk/sgcs/NimoReadinessDriverTest.kt +15 -9
- package/build/BluetoothSdk.types.d.ts +2 -0
- package/build/BluetoothSdk.types.d.ts.map +1 -1
- package/build/BluetoothSdk.types.js +1 -0
- package/build/BluetoothSdk.types.js.map +1 -1
- package/build/_private/BluetoothSdkModule.d.ts +2 -0
- package/build/_private/BluetoothSdkModule.d.ts.map +1 -1
- package/build/_private/BluetoothSdkModule.js.map +1 -1
- package/build/generated/releaseMetadata.js +5 -5
- package/build/generated/releaseMetadata.js.map +1 -1
- package/ios/BluetoothSdkModule.swift +4 -0
- package/ios/Source/BluetoothSdkDefaults.swift +1 -1
- package/ios/Source/DeviceManager.swift +66 -50
- package/ios/Source/GeneratedReleaseMetadata.swift +5 -5
- package/ios/Source/audio/GlassesMicWatchdog.swift +33 -0
- package/ios/Source/sgcs/Ar99.swift +1 -2
- package/ios/Source/sgcs/G2.swift +259 -245
- package/ios/Source/sgcs/G2ConnectionSession.swift +101 -0
- package/ios/Source/sgcs/Nimo.swift +104 -43
- package/ios/Source/sgcs/NimoCanvasCoordinator.swift +20 -6
- package/ios/Source/sgcs/NimoCanvasSession.swift +30 -14
- package/ios/Source/sgcs/NimoDiscovery.swift +49 -0
- package/ios/Source/status/DeviceStatus.swift +1 -0
- package/ios/Source/stt/SherpaOnnxTranscriber.swift +49 -189
- package/ios/Source/utils/DemandDrivenAudioWorker.swift +150 -0
- package/ios/Tests/DemandDrivenAudioWorkerTests.swift +157 -0
- package/ios/Tests/DeviceManagerReconnectTests.swift +362 -0
- package/ios/Tests/G2ConnectionSessionTests.swift +172 -0
- package/ios/Tests/NimoCanvasSessionTests.swift +74 -5
- package/ios/Tests/NimoCanvasTransportTests.swift +25 -0
- package/ios/Tests/NimoDeviceCommandsTests.swift +16 -0
- package/ios/Tests/NimoDiscoveryTests.swift +79 -0
- package/package.json +2 -2
- package/src/BluetoothSdk.types.ts +3 -0
- package/src/_private/BluetoothSdkModule.ts +2 -0
- package/src/generated/releaseMetadata.ts +5 -5
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
|
|
3
|
+
/// Readiness belongs to an authenticated pair and a completed initialization attempt, never to
|
|
4
|
+
/// a gesture from one arm. A generation retires asynchronous setup when either arm is lost.
|
|
5
|
+
struct G2ConnectionSession {
|
|
6
|
+
enum Side: String, CaseIterable {
|
|
7
|
+
case left = "L"
|
|
8
|
+
case right = "R"
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
private(set) var generation: UInt64 = 0
|
|
12
|
+
private var initialized: Set<Side> = []
|
|
13
|
+
private var authenticatedSides: Set<Side> = []
|
|
14
|
+
private var authStarted = false
|
|
15
|
+
private var setupComplete = false
|
|
16
|
+
|
|
17
|
+
var isReady: Bool {
|
|
18
|
+
initialized.count == 2 && authenticatedSides.count == 2 && setupComplete
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
func needsRecovery(attempt: UInt64) -> Bool {
|
|
22
|
+
generation == attempt && !isReady
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
mutating func initialize(_ side: Side) {
|
|
26
|
+
initialized.insert(side)
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
mutating func beginAuthentication() -> UInt64? {
|
|
30
|
+
guard initialized.count == 2, !authStarted else { return nil }
|
|
31
|
+
authStarted = true
|
|
32
|
+
return generation
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
mutating func authenticated(_ side: Side, success: Bool) {
|
|
36
|
+
guard authStarted else { return }
|
|
37
|
+
if success { authenticatedSides.insert(side) }
|
|
38
|
+
else { authenticatedSides.remove(side) }
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
mutating func completeSetup(generation: UInt64) {
|
|
42
|
+
guard self.generation == generation, authStarted else { return }
|
|
43
|
+
setupComplete = true
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
mutating func reset() {
|
|
47
|
+
generation &+= 1
|
|
48
|
+
initialized.removeAll()
|
|
49
|
+
authenticatedSides.removeAll()
|
|
50
|
+
authStarted = false
|
|
51
|
+
setupComplete = false
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
enum G2ConnectionTarget {
|
|
56
|
+
/// A serial-scoped cached UUID identifies an arm even without a new advertisement. Nearby
|
|
57
|
+
/// arms can share the G2_32 name fragment; only an advertisement's serial can select an
|
|
58
|
+
/// uncached arm. Never fall back to names alone or another pair's cached identifiers.
|
|
59
|
+
static func side(
|
|
60
|
+
identifier: UUID, name: String?, advertisedSerial: String?, searchID: String,
|
|
61
|
+
leftUUID: UUID?, rightUUID: UUID?
|
|
62
|
+
) -> G2ConnectionSession.Side? {
|
|
63
|
+
guard !searchID.isEmpty, searchID != "NOT_SET" else { return nil }
|
|
64
|
+
if identifier == leftUUID, identifier == rightUUID { return nil }
|
|
65
|
+
if identifier == leftUUID { return .left }
|
|
66
|
+
if identifier == rightUUID { return .right }
|
|
67
|
+
guard let advertisedSerial, advertisedSerial.contains(searchID), let name else { return nil }
|
|
68
|
+
if name.contains("_L_") { return .left }
|
|
69
|
+
if name.contains("_R_") { return .right }
|
|
70
|
+
return nil
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/// Normal pairing connects the arms sequentially. Surface a missing arm only after the
|
|
75
|
+
/// same partial link has persisted for three seconds, using monotonic time.
|
|
76
|
+
struct G2PartialConnection {
|
|
77
|
+
static let noticeDelay: TimeInterval = 3
|
|
78
|
+
private(set) var missingSide: G2ConnectionSession.Side?
|
|
79
|
+
private var partialSince: TimeInterval = 0
|
|
80
|
+
|
|
81
|
+
mutating func update(leftConnected: Bool, rightConnected: Bool, now: TimeInterval) {
|
|
82
|
+
let missing: G2ConnectionSession.Side? = leftConnected == rightConnected
|
|
83
|
+
? nil : (leftConnected ? .right : .left)
|
|
84
|
+
if missing != missingSide {
|
|
85
|
+
missingSide = missing
|
|
86
|
+
partialSince = now
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
func visibleMissingSide(now: TimeInterval) -> G2ConnectionSession.Side? {
|
|
91
|
+
now - partialSince >= Self.noticeDelay ? missingSide : nil
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
var timeoutError: String {
|
|
95
|
+
switch missingSide {
|
|
96
|
+
case .left: "errors:g2LeftArmUnavailable"
|
|
97
|
+
case .right: "errors:g2RightArmUnavailable"
|
|
98
|
+
case nil: "errors:g2ConnectionTimedOut"
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
}
|
|
@@ -20,10 +20,6 @@ enum NimoBLE {
|
|
|
20
20
|
static let CHAR_RX = CBUUID(string: "00002022-0000-1000-8000-00805F9B34FB")
|
|
21
21
|
static let CHAR_MIC = CBUUID(string: "00002025-0000-1000-8000-00805F9B34FB")
|
|
22
22
|
|
|
23
|
-
static let NAME_PREFIX = "nimo"
|
|
24
|
-
/// iOS ANCS side-channel devices advertise "<name>_ble" — never the data channel.
|
|
25
|
-
static let BLE_NAME_SUFFIX = "_ble"
|
|
26
|
-
|
|
27
23
|
static let CHUNK_SIZE = 501
|
|
28
24
|
static let INTER_FRAME_DELAY_MS = 5
|
|
29
25
|
}
|
|
@@ -44,6 +40,8 @@ enum NimoProtocol {
|
|
|
44
40
|
static let CMD_SET_PARAMETER = 0x03
|
|
45
41
|
static let CMD_INSTRUCTION_REPORT = 0x06
|
|
46
42
|
static let CMD_CONTROL_INSTRUCTION = 0x07
|
|
43
|
+
static let CMD_CONTROL_FACTORY = 0x08
|
|
44
|
+
static let FACTORY_RECOVER = 0x03
|
|
47
45
|
static let CMD_CONTROL_NOTIFICATION = 0x09
|
|
48
46
|
|
|
49
47
|
// get parameter keys
|
|
@@ -62,6 +60,9 @@ enum NimoProtocol {
|
|
|
62
60
|
static let SET_DISPLAY_OFF = 0x0F
|
|
63
61
|
static let SET_PHONE_TYPE = 0x14
|
|
64
62
|
static let SET_HEIGHT_LEVEL = 0x17
|
|
63
|
+
// Dynamic-v1 firmware: 1-based UI locale; 1 selects English.
|
|
64
|
+
static let SET_SYSTEM_LANGUAGE = 0x24
|
|
65
|
+
static let LANGUAGE_ENGLISH: UInt8 = 0x01
|
|
65
66
|
|
|
66
67
|
// control instruction keys
|
|
67
68
|
static let CTRL_ENTER_APP = 0x01
|
|
@@ -591,6 +592,27 @@ class Nimo: NSObject, SGCManager {
|
|
|
591
592
|
private var rxChar: CBCharacteristic?
|
|
592
593
|
private var micChar: CBCharacteristic?
|
|
593
594
|
private var isDisconnecting = false
|
|
595
|
+
private var scanRequested = false
|
|
596
|
+
private lazy var discovery = NimoDiscovery<CBPeripheral>(
|
|
597
|
+
register: { [weak self] in
|
|
598
|
+
#if os(iOS)
|
|
599
|
+
self?.centralManager?.registerForConnectionEvents(options: [.serviceUUIDs: [NimoBLE.SERVICE_UUID]])
|
|
600
|
+
#endif
|
|
601
|
+
}, connected: { [weak self] in
|
|
602
|
+
self?.centralManager?.retrieveConnectedPeripherals(withServices: [NimoBLE.SERVICE_UUID]) ?? []
|
|
603
|
+
}, scan: { [weak self] in
|
|
604
|
+
self?.centralManager?.scanForPeripherals(
|
|
605
|
+
withServices: nil, options: [CBCentralManagerScanOptionAllowDuplicatesKey: false]
|
|
606
|
+
)
|
|
607
|
+
}, stop: { [weak self] in
|
|
608
|
+
self?.centralManager?.stopScan()
|
|
609
|
+
#if os(iOS)
|
|
610
|
+
self?.centralManager?.registerForConnectionEvents(options: [:])
|
|
611
|
+
#endif
|
|
612
|
+
}, name: { $0.name }, found: { [weak self] peripheral, name, rssi in
|
|
613
|
+
self?.onDeviceFound(peripheral, name: name, rssi: rssi)
|
|
614
|
+
}
|
|
615
|
+
)
|
|
594
616
|
|
|
595
617
|
/// Device search
|
|
596
618
|
private var DEVICE_SEARCH_ID = "NOT_SET"
|
|
@@ -680,6 +702,8 @@ class Nimo: NSObject, SGCManager {
|
|
|
680
702
|
func findCompatibleDevices() {
|
|
681
703
|
Bridge.log("NIMO: findCompatibleDevices()")
|
|
682
704
|
DEVICE_SEARCH_ID = "NOT_SET"
|
|
705
|
+
isDisconnecting = false
|
|
706
|
+
scanRequested = true
|
|
683
707
|
DeviceStore.shared.apply("glasses", "connectionState", ConnTypes.SCANNING)
|
|
684
708
|
startScan()
|
|
685
709
|
}
|
|
@@ -687,6 +711,7 @@ class Nimo: NSObject, SGCManager {
|
|
|
687
711
|
func connectById(_ id: String) {
|
|
688
712
|
Bridge.log("NIMO: connectById(\(id))")
|
|
689
713
|
DEVICE_SEARCH_ID = id
|
|
714
|
+
scanRequested = true
|
|
690
715
|
DeviceStore.shared.apply("glasses", "connectionState", ConnTypes.CONNECTING)
|
|
691
716
|
isDisconnecting = false
|
|
692
717
|
startPairingTimeout()
|
|
@@ -694,7 +719,8 @@ class Nimo: NSObject, SGCManager {
|
|
|
694
719
|
}
|
|
695
720
|
|
|
696
721
|
func stopScan() {
|
|
697
|
-
|
|
722
|
+
scanRequested = false
|
|
723
|
+
discovery.cancel()
|
|
698
724
|
}
|
|
699
725
|
|
|
700
726
|
func disconnect() {
|
|
@@ -721,6 +747,27 @@ class Nimo: NSObject, SGCManager {
|
|
|
721
747
|
DeviceStore.shared.apply("glasses", "connectionState", ConnTypes.DISCONNECTED)
|
|
722
748
|
}
|
|
723
749
|
|
|
750
|
+
/// Explicit Unpair only; passive forget/cleanup must never reset the glasses.
|
|
751
|
+
func resetForUnpair(onResult: @escaping (Bool) -> Void) {
|
|
752
|
+
guard peripheral != nil, txChar != nil, handshakeState == .ready else {
|
|
753
|
+
Bridge.log("NIMO: unpair while offline; remote reset unavailable")
|
|
754
|
+
onResult(false)
|
|
755
|
+
return
|
|
756
|
+
}
|
|
757
|
+
isDisconnecting = true
|
|
758
|
+
stopTimers()
|
|
759
|
+
Task { await reconnectionManager.stop() }
|
|
760
|
+
canvasEncoder.invalidate()
|
|
761
|
+
canvas.disconnected()
|
|
762
|
+
Bridge.log("NIMO: sending factory reset for explicit Unpair")
|
|
763
|
+
sendAwaitingAck(cmd: NimoProtocol.CMD_CONTROL_FACTORY, key: NimoProtocol.FACTORY_RECOVER,
|
|
764
|
+
payload: Data())
|
|
765
|
+
{ success in
|
|
766
|
+
Bridge.log("NIMO: factory reset acknowledged=\(success)")
|
|
767
|
+
onResult(success)
|
|
768
|
+
}
|
|
769
|
+
}
|
|
770
|
+
|
|
724
771
|
func forget() {
|
|
725
772
|
Bridge.log("NIMO: forget()")
|
|
726
773
|
disconnect()
|
|
@@ -1061,13 +1108,9 @@ class Nimo: NSObject, SGCManager {
|
|
|
1061
1108
|
|
|
1062
1109
|
// MARK: - BLE Scanning
|
|
1063
1110
|
|
|
1064
|
-
private func isNimoMainDevice(_ name: String) -> Bool {
|
|
1065
|
-
let lower = name.lowercased()
|
|
1066
|
-
return lower.hasPrefix(NimoBLE.NAME_PREFIX) && !lower.hasSuffix(NimoBLE.BLE_NAME_SUFFIX)
|
|
1067
|
-
}
|
|
1068
|
-
|
|
1069
1111
|
@discardableResult
|
|
1070
1112
|
private func startScan() -> Bool {
|
|
1113
|
+
guard scanRequested, !isDisconnecting else { return false }
|
|
1071
1114
|
Bridge.log("NIMO: startScan()")
|
|
1072
1115
|
if centralManager == nil {
|
|
1073
1116
|
centralManager = CBCentralManager(
|
|
@@ -1083,17 +1126,32 @@ class Nimo: NSObject, SGCManager {
|
|
|
1083
1126
|
|
|
1084
1127
|
// Fast path: reconnect to the cached peripheral UUID.
|
|
1085
1128
|
if connectByUUID() {
|
|
1129
|
+
stopScan()
|
|
1086
1130
|
return true
|
|
1087
1131
|
}
|
|
1088
1132
|
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
)
|
|
1133
|
+
// NIMO's Companion service is GATT over BR/EDR. The main device must
|
|
1134
|
+
// first be paired in Settings; it does not appear in a BLE-only scan.
|
|
1135
|
+
discovery.start()
|
|
1093
1136
|
return true
|
|
1094
1137
|
}
|
|
1095
1138
|
|
|
1139
|
+
private func onDeviceFound(_ peripheral: CBPeripheral, name: String, rssi: Int?) {
|
|
1140
|
+
Bridge.log("NIMO: found Companion device: \(name)")
|
|
1141
|
+
Bridge.sendDiscoveredDevice(DeviceTypes.NIMO, name, rssi: rssi)
|
|
1142
|
+
guard DEVICE_SEARCH_ID != "NOT_SET", name == DEVICE_SEARCH_ID,
|
|
1143
|
+
self.peripheral == nil else { return }
|
|
1144
|
+
Bridge.log("NIMO: Connecting to \(name)")
|
|
1145
|
+
stopScan()
|
|
1146
|
+
lastDeviceName = name
|
|
1147
|
+
lastDeviceUUID = peripheral.identifier.uuidString
|
|
1148
|
+
self.peripheral = peripheral
|
|
1149
|
+
peripheral.delegate = self
|
|
1150
|
+
centralManager?.connect(peripheral, options: nil)
|
|
1151
|
+
}
|
|
1152
|
+
|
|
1096
1153
|
private func connectByUUID() -> Bool {
|
|
1154
|
+
guard peripheral == nil else { return false }
|
|
1097
1155
|
guard DEVICE_SEARCH_ID != "NOT_SET", !DEVICE_SEARCH_ID.isEmpty else { return false }
|
|
1098
1156
|
guard lastDeviceName == DEVICE_SEARCH_ID,
|
|
1099
1157
|
let uuidString = lastDeviceUUID,
|
|
@@ -1255,6 +1313,9 @@ class Nimo: NSObject, SGCManager {
|
|
|
1255
1313
|
needsAck: false
|
|
1256
1314
|
)
|
|
1257
1315
|
)
|
|
1316
|
+
// Optional setting: older firmware may reject it without preventing connection.
|
|
1317
|
+
sendFrame(NimoFrameCodec.encodeFrame(cmd: NimoProtocol.CMD_SET_PARAMETER,
|
|
1318
|
+
key: NimoProtocol.SET_SYSTEM_LANGUAGE, payload: Data([NimoProtocol.LANGUAGE_ENGLISH])))
|
|
1258
1319
|
getBatteryStatus()
|
|
1259
1320
|
requestVersionInfo()
|
|
1260
1321
|
|
|
@@ -1264,7 +1325,10 @@ class Nimo: NSObject, SGCManager {
|
|
|
1264
1325
|
DeviceStore.shared.apply("glasses", "fullyBooted", true)
|
|
1265
1326
|
DeviceStore.shared.apply("glasses", "connectionState", ConnTypes.CONNECTED)
|
|
1266
1327
|
startTimers()
|
|
1267
|
-
|
|
1328
|
+
// The Launch ACK validates peer readiness without waiting for the first
|
|
1329
|
+
// unsolicited heartbeat (which can arrive a minute after connection).
|
|
1330
|
+
canvas.activate()
|
|
1331
|
+
canvas.readiness(twsConnected && peerCompanionReady != false)
|
|
1268
1332
|
}
|
|
1269
1333
|
|
|
1270
1334
|
private func handshakeFailed() {
|
|
@@ -1299,7 +1363,8 @@ class Nimo: NSObject, SGCManager {
|
|
|
1299
1363
|
}
|
|
1300
1364
|
Bridge.log("NIMO: Attempting reconnection...")
|
|
1301
1365
|
await MainActor.run {
|
|
1302
|
-
self.isDisconnecting
|
|
1366
|
+
guard !self.isDisconnecting else { return }
|
|
1367
|
+
self.scanRequested = true
|
|
1303
1368
|
self.startScan()
|
|
1304
1369
|
}
|
|
1305
1370
|
return false
|
|
@@ -1332,6 +1397,7 @@ class Nimo: NSObject, SGCManager {
|
|
|
1332
1397
|
private func handleRxPacket(_ packet: Data) {
|
|
1333
1398
|
if packet.count >= 10, packet[8] == 7, [1, 3, 4].contains(packet[9]) {
|
|
1334
1399
|
if let response = NimoCanvasCodec.response(packet) {
|
|
1400
|
+
if response.key == 1 { Bridge.log("NIMO: canvas Launch response status=\(response.payload.first.map(Int.init) ?? -1)") }
|
|
1335
1401
|
canvas.response(key: response.key, payload: response.payload)
|
|
1336
1402
|
}
|
|
1337
1403
|
return
|
|
@@ -1365,9 +1431,9 @@ class Nimo: NSObject, SGCManager {
|
|
|
1365
1431
|
Bridge.log("NIMO: app state report appId=\(appId) phase=\(phase)")
|
|
1366
1432
|
switch phase {
|
|
1367
1433
|
case NimoProtocol.STATE_ENTER:
|
|
1368
|
-
|
|
1434
|
+
canvas.nativeApp(appId, entered: true)
|
|
1369
1435
|
case NimoProtocol.STATE_EXIT:
|
|
1370
|
-
|
|
1436
|
+
canvas.nativeApp(appId, entered: false)
|
|
1371
1437
|
default:
|
|
1372
1438
|
break
|
|
1373
1439
|
}
|
|
@@ -1397,7 +1463,7 @@ class Nimo: NSObject, SGCManager {
|
|
|
1397
1463
|
peerCompanionReady = v[9] != 0
|
|
1398
1464
|
onTwsState(Int(v[8]) >= 1)
|
|
1399
1465
|
if handshakeState == .ready {
|
|
1400
|
-
canvas.readiness(twsConnected && peerCompanionReady
|
|
1466
|
+
canvas.readiness(twsConnected && peerCompanionReady != false, confirmed: true)
|
|
1401
1467
|
}
|
|
1402
1468
|
}
|
|
1403
1469
|
case NimoProtocol.BUSINESS_BATTERY:
|
|
@@ -1420,7 +1486,7 @@ class Nimo: NSObject, SGCManager {
|
|
|
1420
1486
|
if !connected, handshakeState == .ready {
|
|
1421
1487
|
Bridge.log("NIMO: TWS service dropped mid-session (arm removed/off?)")
|
|
1422
1488
|
}
|
|
1423
|
-
if handshakeState == .ready { canvas.readiness(connected && peerCompanionReady
|
|
1489
|
+
if handshakeState == .ready { canvas.readiness(connected && peerCompanionReady != false) }
|
|
1424
1490
|
}
|
|
1425
1491
|
|
|
1426
1492
|
private func handleInputEvent(_ code: Int) {
|
|
@@ -1456,6 +1522,9 @@ class Nimo: NSObject, SGCManager {
|
|
|
1456
1522
|
}
|
|
1457
1523
|
|
|
1458
1524
|
private func handleResponse(cmd: Int, key: Int, statusCode: Int, data: Data) {
|
|
1525
|
+
if cmd == NimoProtocol.CMD_SET_PARAMETER, key == NimoProtocol.SET_SYSTEM_LANGUAGE {
|
|
1526
|
+
Bridge.log("NIMO: English menu setting acknowledged=\(statusCode == 0) status=\(statusCode)")
|
|
1527
|
+
}
|
|
1459
1528
|
resolvePendingAck(cmd: cmd, key: key, success: statusCode == 0)
|
|
1460
1529
|
|
|
1461
1530
|
guard cmd == NimoProtocol.CMD_GET_PARAMETER, statusCode == 0 else { return }
|
|
@@ -1543,7 +1612,7 @@ class Nimo: NSObject, SGCManager {
|
|
|
1543
1612
|
guard let opusDecoder else { return }
|
|
1544
1613
|
for opusFrame in packet.opusFrames {
|
|
1545
1614
|
if let pcm = opusDecoder.decode(opusFrame), !pcm.isEmpty {
|
|
1546
|
-
DeviceManager.shared.
|
|
1615
|
+
DeviceManager.shared.handleGlassesPcm(pcm)
|
|
1547
1616
|
}
|
|
1548
1617
|
}
|
|
1549
1618
|
}
|
|
@@ -1564,37 +1633,29 @@ extension Nimo: CBCentralManagerDelegate {
|
|
|
1564
1633
|
}
|
|
1565
1634
|
|
|
1566
1635
|
nonisolated func centralManager(
|
|
1567
|
-
_
|
|
1636
|
+
_: CBCentralManager,
|
|
1568
1637
|
didDiscover peripheral: CBPeripheral,
|
|
1569
1638
|
advertisementData: [String: Any],
|
|
1570
1639
|
rssi: NSNumber
|
|
1571
1640
|
) {
|
|
1572
|
-
|
|
1573
|
-
let name = peripheral.name
|
|
1574
|
-
?? advertisementData[CBAdvertisementDataLocalNameKey] as? String
|
|
1575
|
-
else { return }
|
|
1641
|
+
let advertisedName = advertisementData[CBAdvertisementDataLocalNameKey] as? String
|
|
1576
1642
|
let rssiValue = rssi.intValue
|
|
1577
|
-
|
|
1578
1643
|
DispatchQueue.main.async { [weak self] in
|
|
1579
|
-
|
|
1580
|
-
guard self.isNimoMainDevice(name) else { return }
|
|
1581
|
-
|
|
1582
|
-
Bridge.sendDiscoveredDevice(DeviceTypes.NIMO, name, rssi: rssiValue)
|
|
1583
|
-
|
|
1584
|
-
guard self.DEVICE_SEARCH_ID != "NOT_SET" else { return }
|
|
1585
|
-
guard name == self.DEVICE_SEARCH_ID else { return }
|
|
1586
|
-
guard self.peripheral == nil else { return }
|
|
1587
|
-
|
|
1588
|
-
Bridge.log("NIMO: Connecting to \(name)")
|
|
1589
|
-
self.stopScan()
|
|
1590
|
-
self.lastDeviceName = name
|
|
1591
|
-
self.lastDeviceUUID = peripheral.identifier.uuidString
|
|
1592
|
-
self.peripheral = peripheral
|
|
1593
|
-
peripheral.delegate = self
|
|
1594
|
-
central.connect(peripheral, options: nil)
|
|
1644
|
+
self?.discovery.receive(peripheral, advertisedName: advertisedName, rssi: rssiValue)
|
|
1595
1645
|
}
|
|
1596
1646
|
}
|
|
1597
1647
|
|
|
1648
|
+
#if os(iOS)
|
|
1649
|
+
nonisolated func centralManager(
|
|
1650
|
+
_: CBCentralManager, connectionEventDidOccur event: CBConnectionEvent, for peripheral: CBPeripheral
|
|
1651
|
+
) {
|
|
1652
|
+
guard event == .peerConnected else { return }
|
|
1653
|
+
DispatchQueue.main.async { [weak self] in
|
|
1654
|
+
self?.discovery.receive(peripheral)
|
|
1655
|
+
}
|
|
1656
|
+
}
|
|
1657
|
+
#endif
|
|
1658
|
+
|
|
1598
1659
|
nonisolated func centralManager(_: CBCentralManager, didConnect peripheral: CBPeripheral) {
|
|
1599
1660
|
DispatchQueue.main.async { [weak self] in
|
|
1600
1661
|
guard let self else { return }
|
|
@@ -21,6 +21,7 @@ final class NimoCanvasCoordinator {
|
|
|
21
21
|
private let rejected: (Int) -> Void
|
|
22
22
|
private let session = NimoCanvasSession()
|
|
23
23
|
private var flight: Flight?
|
|
24
|
+
private var cancelRetry: (() -> Void)?
|
|
24
25
|
private var cancelDeadline: (() -> Void)?
|
|
25
26
|
|
|
26
27
|
init(schedule: @escaping NimoScheduler, writeCapacity: @escaping () -> Int,
|
|
@@ -35,23 +36,26 @@ final class NimoCanvasCoordinator {
|
|
|
35
36
|
run(session.offer(bytes, scope: scope, force: force))
|
|
36
37
|
}
|
|
37
38
|
|
|
39
|
+
func activate() {
|
|
40
|
+
run(session.activate())
|
|
41
|
+
}
|
|
42
|
+
|
|
38
43
|
func readiness(_ value: Bool, confirmed: Bool = false) {
|
|
44
|
+
if !value { cancelRetry?(); cancelRetry = nil }
|
|
39
45
|
run(session.readiness(value, confirmed: confirmed))
|
|
40
46
|
}
|
|
41
47
|
|
|
42
48
|
func exit() {
|
|
49
|
+
cancelRetry?(); cancelRetry = nil
|
|
43
50
|
run(session.exit())
|
|
44
51
|
}
|
|
45
52
|
|
|
46
|
-
|
|
47
|
-
func nativeApp(_ appId: Int, entered: Bool) -> Bool {
|
|
48
|
-
let takeover = (appId == NimoCanvasCodec.appId && !entered) || (appId != NimoCanvasCodec.appId && entered)
|
|
49
|
-
let expectedExit = appId == NimoCanvasCodec.appId && !entered && flight?.key == 3
|
|
53
|
+
func nativeApp(_ appId: Int, entered: Bool) {
|
|
50
54
|
run(session.nativeApp(appId, entered: entered))
|
|
51
|
-
return takeover && !expectedExit
|
|
52
55
|
}
|
|
53
56
|
|
|
54
57
|
func disconnected() {
|
|
58
|
+
cancelRetry?(); cancelRetry = nil
|
|
55
59
|
cancelDeadline?(); cancelDeadline = nil
|
|
56
60
|
flight = nil
|
|
57
61
|
session.disconnected()
|
|
@@ -72,6 +76,7 @@ final class NimoCanvasCoordinator {
|
|
|
72
76
|
for action in actions {
|
|
73
77
|
switch action {
|
|
74
78
|
case let .send(key, frame, ticket):
|
|
79
|
+
cancelRetry?(); cancelRetry = nil
|
|
75
80
|
cancelDeadline?()
|
|
76
81
|
let current = Flight(key: key)
|
|
77
82
|
flight = current
|
|
@@ -93,7 +98,16 @@ final class NimoCanvasCoordinator {
|
|
|
93
98
|
disconnected(); reconnect("Canvas framing failed: \(error)")
|
|
94
99
|
}
|
|
95
100
|
case let .reconnect(reason): disconnected(); reconnect(reason)
|
|
96
|
-
case let .rejected(status):
|
|
101
|
+
case let .rejected(status):
|
|
102
|
+
rejected(status)
|
|
103
|
+
if status == 7 {
|
|
104
|
+
cancelRetry?()
|
|
105
|
+
cancelRetry = schedule(1) { [weak self] in
|
|
106
|
+
guard let self else { return }
|
|
107
|
+
self.cancelRetry = nil
|
|
108
|
+
self.run(self.session.retryNotReady())
|
|
109
|
+
}
|
|
110
|
+
}
|
|
97
111
|
}
|
|
98
112
|
}
|
|
99
113
|
}
|
|
@@ -15,12 +15,14 @@ final class NimoCanvasSession {
|
|
|
15
15
|
}
|
|
16
16
|
|
|
17
17
|
private var ready = false
|
|
18
|
+
private var transportReady = false
|
|
18
19
|
private var active = false
|
|
19
20
|
private var locked = false
|
|
20
21
|
private var launchBlocked = false
|
|
21
22
|
private var waitingReadiness = false
|
|
22
23
|
private var observedNotReady = false
|
|
23
24
|
private var readinessRetries = 0
|
|
25
|
+
private var probeRetries = 0
|
|
24
26
|
private var exitRequested = false
|
|
25
27
|
private var desired: Data?
|
|
26
28
|
private var accepted: Data?
|
|
@@ -39,9 +41,27 @@ final class NimoCanvasSession {
|
|
|
39
41
|
return pump()
|
|
40
42
|
}
|
|
41
43
|
|
|
44
|
+
/// Enter a blank canvas unless a newer host scene survived the connection change.
|
|
45
|
+
func activate() -> [Action] {
|
|
46
|
+
if desired == nil { desired = Data([0, 0, 1]) }
|
|
47
|
+
exitRequested = false
|
|
48
|
+
return pump()
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/// The firmware validates peer readiness on Launch. Only an explicit NOT_READY
|
|
52
|
+
/// permits a bounded retry; an ambiguous ACK still requires a new transport.
|
|
53
|
+
func retryNotReady() -> [Action] {
|
|
54
|
+
guard transportReady, waitingReadiness, desired != nil, probeRetries < 3 else { return [] }
|
|
55
|
+
probeRetries += 1
|
|
56
|
+
waitingReadiness = false
|
|
57
|
+
ready = true
|
|
58
|
+
return pump()
|
|
59
|
+
}
|
|
60
|
+
|
|
42
61
|
/// Only a fresh heartbeat samples both TWS and peer Companion readiness.
|
|
43
62
|
func readiness(_ value: Bool, confirmed: Bool = false) -> [Action] {
|
|
44
|
-
|
|
63
|
+
transportReady = value
|
|
64
|
+
if !value { readinessRetries = 0; probeRetries = 0 }
|
|
45
65
|
if !value, flight != nil { return resetLink("Readiness lost during canvas command") }
|
|
46
66
|
if waitingReadiness {
|
|
47
67
|
if !value { observedNotReady = true }
|
|
@@ -58,8 +78,8 @@ final class NimoCanvasSession {
|
|
|
58
78
|
|
|
59
79
|
/// Keep only the latest desired scene across a new transport generation.
|
|
60
80
|
func disconnected() {
|
|
61
|
-
ready = false; active = false; locked = false; launchBlocked = false
|
|
62
|
-
waitingReadiness = false; observedNotReady = false; readinessRetries = 0
|
|
81
|
+
ready = false; transportReady = false; active = false; locked = false; launchBlocked = false
|
|
82
|
+
waitingReadiness = false; observedNotReady = false; readinessRetries = 0; probeRetries = 0
|
|
63
83
|
accepted = nil; rejected = nil; flight = nil
|
|
64
84
|
ticket &+= 1; forceRevision &+= 1
|
|
65
85
|
}
|
|
@@ -69,16 +89,12 @@ final class NimoCanvasSession {
|
|
|
69
89
|
return pump()
|
|
70
90
|
}
|
|
71
91
|
|
|
92
|
+
/// Stock UI reports do not relinquish host ownership. Replay after the current ACK.
|
|
72
93
|
func nativeApp(_ appId: Int, entered: Bool) -> [Action] {
|
|
73
94
|
if (appId == NimoCanvasCodec.appId && !entered) || (appId != NimoCanvasCodec.appId && entered) {
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
return []
|
|
78
|
-
}
|
|
79
|
-
let ambiguous = flight != nil
|
|
80
|
-
desired = nil; accepted = nil; rejected = nil; active = false; exitRequested = false; scope = nil
|
|
81
|
-
if ambiguous { return resetLink("Native app transition interrupted canvas command") }
|
|
95
|
+
active = false
|
|
96
|
+
accepted = nil
|
|
97
|
+
return pump()
|
|
82
98
|
}
|
|
83
99
|
return []
|
|
84
100
|
}
|
|
@@ -112,8 +128,8 @@ final class NimoCanvasSession {
|
|
|
112
128
|
return [.rejected(status)] + pump()
|
|
113
129
|
}
|
|
114
130
|
switch key {
|
|
115
|
-
case 1: active = true
|
|
116
|
-
case 4: accepted = current.frame; rejected = nil; readinessRetries = 0
|
|
131
|
+
case 1: active = true; accepted = nil
|
|
132
|
+
case 4: accepted = current.frame; rejected = nil; readinessRetries = 0; probeRetries = 0
|
|
117
133
|
case 3: active = false; accepted = nil; exitRequested = false
|
|
118
134
|
default: break
|
|
119
135
|
}
|
|
@@ -126,7 +142,7 @@ final class NimoCanvasSession {
|
|
|
126
142
|
}
|
|
127
143
|
|
|
128
144
|
private func resetLink(_ reason: String) -> [Action] {
|
|
129
|
-
ready = false; active = false; accepted = nil; flight = nil
|
|
145
|
+
ready = false; transportReady = false; active = false; accepted = nil; flight = nil
|
|
130
146
|
ticket &+= 1; forceRevision &+= 1
|
|
131
147
|
return [.reconnect(reason)]
|
|
132
148
|
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
|
|
3
|
+
/// Combines system-connected classic devices with incoming CoreBluetooth discoveries.
|
|
4
|
+
/// Kept independent of the radio so discovery can be exercised without hardware.
|
|
5
|
+
@MainActor
|
|
6
|
+
final class NimoDiscovery<Peripheral> {
|
|
7
|
+
private(set) var active = false
|
|
8
|
+
private let register: () -> Void
|
|
9
|
+
private let connected: () -> [Peripheral]
|
|
10
|
+
private let scan: () -> Void
|
|
11
|
+
private let stop: () -> Void
|
|
12
|
+
private let name: (Peripheral) -> String?
|
|
13
|
+
private let found: (Peripheral, String, Int?) -> Void
|
|
14
|
+
|
|
15
|
+
init(register: @escaping () -> Void, connected: @escaping () -> [Peripheral],
|
|
16
|
+
scan: @escaping () -> Void, stop: @escaping () -> Void,
|
|
17
|
+
name: @escaping (Peripheral) -> String?, found: @escaping (Peripheral, String, Int?) -> Void)
|
|
18
|
+
{
|
|
19
|
+
self.register = register
|
|
20
|
+
self.connected = connected
|
|
21
|
+
self.scan = scan
|
|
22
|
+
self.stop = stop
|
|
23
|
+
self.name = name
|
|
24
|
+
self.found = found
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
func start() {
|
|
28
|
+
active = true
|
|
29
|
+
// Register first so pairing in Settings cannot race the connected snapshot.
|
|
30
|
+
register()
|
|
31
|
+
for peripheral in connected() {
|
|
32
|
+
receive(peripheral)
|
|
33
|
+
}
|
|
34
|
+
if active { scan() }
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
func cancel() {
|
|
38
|
+
active = false
|
|
39
|
+
stop()
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
func receive(_ peripheral: Peripheral, advertisedName: String? = nil, rssi: Int? = nil) {
|
|
43
|
+
guard active, let name = advertisedName ?? name(peripheral) else { return }
|
|
44
|
+
let lower = name.lowercased()
|
|
45
|
+
// The separate ANCS peripheral does not host the Companion data service.
|
|
46
|
+
guard lower.hasPrefix("nimo"), !lower.hasSuffix("_ble") else { return }
|
|
47
|
+
found(peripheral, name, rssi)
|
|
48
|
+
}
|
|
49
|
+
}
|