@mentra/bluetooth-sdk 0.1.14 → 0.1.16
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 +4 -6
- package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkModule.kt +22 -9
- package/android/src/main/java/com/mentra/bluetoothsdk/Bridge.kt +5 -20
- package/android/src/main/java/com/mentra/bluetoothsdk/DeviceManager.kt +358 -261
- package/android/src/main/java/com/mentra/bluetoothsdk/DeviceStore.kt +3 -3
- package/android/src/main/java/com/mentra/bluetoothsdk/MentraBluetoothSdk.kt +133 -52
- package/android/src/main/java/com/mentra/bluetoothsdk/OtaManifest.kt +12 -12
- package/android/src/main/java/com/mentra/bluetoothsdk/camera/CameraModels.kt +22 -6
- package/android/src/main/java/com/mentra/bluetoothsdk/events/BluetoothEvents.kt +1 -22
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/G1.kt +50 -3
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/G2.kt +1184 -795
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraLive.kt +422 -78
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraNex.kt +3 -4
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/Nimo.kt +2532 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/SGCManager.kt +15 -1
- package/android/src/main/java/com/mentra/bluetoothsdk/types/DeviceModels.kt +5 -3
- package/android/src/main/java/com/mentra/bluetoothsdk/utils/Constants.kt +2 -1
- package/android/src/test/java/com/mentra/bluetoothsdk/camera/PhotoRequestTest.kt +60 -1
- package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/NimoProtocolTest.kt +333 -0
- package/build/BluetoothSdk.types.d.ts +39 -14
- 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/_internal.d.ts +4 -4
- package/build/_internal.js +4 -4
- package/build/_internal.js.map +1 -1
- package/build/_private/BluetoothSdkModule.d.ts +11 -3
- package/build/_private/BluetoothSdkModule.d.ts.map +1 -1
- package/build/_private/BluetoothSdkModule.js +5 -0
- package/build/_private/BluetoothSdkModule.js.map +1 -1
- package/build/_private/cameraRequestPayload.d.ts +4 -0
- package/build/_private/cameraRequestPayload.d.ts.map +1 -0
- package/build/_private/cameraRequestPayload.js +25 -0
- package/build/_private/cameraRequestPayload.js.map +1 -0
- package/build/_private/photoRequestPayload.d.ts.map +1 -1
- package/build/_private/photoRequestPayload.js +8 -2
- package/build/_private/photoRequestPayload.js.map +1 -1
- package/build/index.d.ts +1 -1
- package/build/index.d.ts.map +1 -1
- package/build/index.js +72 -54
- package/build/index.js.map +1 -1
- package/ios/BluetoothSdkModule.swift +40 -12
- package/ios/Source/Bridge.swift +4 -16
- package/ios/Source/DeviceManager.swift +72 -12
- package/ios/Source/MentraBluetoothSDK.swift +120 -50
- package/ios/Source/OtaManifest.swift +13 -13
- package/ios/Source/camera/CameraModels.swift +75 -9
- package/ios/Source/errors/{BluetoothError.swift → BluetoothSdkError.swift} +1 -1
- package/ios/Source/events/BluetoothEvents.swift +5 -27
- package/ios/Source/internal/BluetoothAvailability.swift +5 -5
- package/ios/Source/sgcs/G1.swift +51 -1
- package/ios/Source/sgcs/G2.swift +643 -203
- package/ios/Source/sgcs/MentraLive.swift +53 -39
- package/ios/Source/sgcs/Nimo.swift +1928 -0
- package/ios/Source/sgcs/SGCManager.swift +12 -5
- package/ios/Source/stt/STTTools.swift +1 -1
- package/ios/Source/types/DeviceModels.swift +5 -0
- package/ios/Source/utils/Constants.swift +2 -0
- package/package.json +6 -1
- package/src/BluetoothSdk.types.ts +42 -17
- package/src/_internal.ts +4 -4
- package/src/_private/BluetoothSdkModule.ts +18 -2
- package/src/_private/cameraRequestPayload.ts +29 -0
- package/src/_private/photoRequestPayload.ts +9 -2
- package/src/index.ts +76 -58
|
@@ -0,0 +1,1928 @@
|
|
|
1
|
+
//
|
|
2
|
+
// Nimo.swift
|
|
3
|
+
//
|
|
4
|
+
// SGC for Nimo glasses. Native implementation of the vendor's BLE protocol:
|
|
5
|
+
// a 0xBF-framed UART channel (service 7033) for commands/content, plus a
|
|
6
|
+
// separate H-T-L-V Opus microphone channel (char 2025).
|
|
7
|
+
//
|
|
8
|
+
|
|
9
|
+
import AVFoundation
|
|
10
|
+
import Compression
|
|
11
|
+
import CoreBluetooth
|
|
12
|
+
import Foundation
|
|
13
|
+
import UIKit
|
|
14
|
+
|
|
15
|
+
// MARK: - Nimo BLE Constants
|
|
16
|
+
|
|
17
|
+
enum NimoBLE {
|
|
18
|
+
// UART-style service: TX = phone→glasses commands, RX = glasses→phone notifications.
|
|
19
|
+
// Short UUIDs from the vendor SDK expanded onto the Bluetooth base UUID.
|
|
20
|
+
static let SERVICE_UUID = CBUUID(string: "00007033-0000-1000-8000-00805F9B34FB")
|
|
21
|
+
static let CHAR_TX = CBUUID(string: "00002021-0000-1000-8000-00805F9B34FB")
|
|
22
|
+
static let CHAR_RX = CBUUID(string: "00002022-0000-1000-8000-00805F9B34FB")
|
|
23
|
+
static let CHAR_MIC = CBUUID(string: "00002025-0000-1000-8000-00805F9B34FB")
|
|
24
|
+
|
|
25
|
+
static let NAME_PREFIX = "nimo"
|
|
26
|
+
// iOS ANCS side-channel devices advertise "<name>_ble" — never the data channel.
|
|
27
|
+
static let BLE_NAME_SUFFIX = "_ble"
|
|
28
|
+
|
|
29
|
+
static let CHUNK_SIZE = 501
|
|
30
|
+
static let INTER_FRAME_DELAY_MS = 5
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// MARK: - Nimo Protocol Constants
|
|
34
|
+
// Byte values follow the glasses firmware protocol and must not be changed.
|
|
35
|
+
|
|
36
|
+
enum NimoProtocol {
|
|
37
|
+
static let FRAME_MAGIC: UInt8 = 0xBF
|
|
38
|
+
|
|
39
|
+
// status bits
|
|
40
|
+
static let STATUS_ERR: UInt8 = 0x01
|
|
41
|
+
static let STATUS_ACK: UInt8 = 0x02
|
|
42
|
+
|
|
43
|
+
// command categories
|
|
44
|
+
static let CMD_GET_PARAMETER = 0x02
|
|
45
|
+
static let CMD_SET_PARAMETER = 0x03
|
|
46
|
+
static let CMD_INSTRUCTION_REPORT = 0x06
|
|
47
|
+
static let CMD_CONTROL_INSTRUCTION = 0x07
|
|
48
|
+
static let CMD_CONTROL_NOTIFICATION = 0x09
|
|
49
|
+
|
|
50
|
+
// get parameter keys
|
|
51
|
+
static let GET_BATTERY = 0x06
|
|
52
|
+
static let GET_VERSION = 0x0A
|
|
53
|
+
static let GET_VERSION_DETAIL = 0x0B
|
|
54
|
+
static let GET_TWS_STATUS = 0x16
|
|
55
|
+
|
|
56
|
+
// set parameter keys
|
|
57
|
+
static let SET_TIME = 0x01
|
|
58
|
+
static let SET_BRIGHTNESS = 0x02
|
|
59
|
+
static let SET_DISTANCE = 0x03
|
|
60
|
+
static let SET_ANGLE = 0x04
|
|
61
|
+
static let SET_HEADUP_DISPLAY = 0x0D
|
|
62
|
+
static let SET_AUTO_BRIGHTNESS = 0x0E
|
|
63
|
+
static let SET_DISPLAY_OFF = 0x0F
|
|
64
|
+
static let SET_PHONE_TYPE = 0x14
|
|
65
|
+
static let SET_HEIGHT_LEVEL = 0x17
|
|
66
|
+
|
|
67
|
+
// control instruction keys
|
|
68
|
+
static let CTRL_ENTER_APP = 0x01
|
|
69
|
+
static let CTRL_QUIT_APP = 0x03
|
|
70
|
+
static let CTRL_UPDATE_CONTENT = 0x04
|
|
71
|
+
|
|
72
|
+
// notification keys
|
|
73
|
+
static let NOTIFICATION_SEND = 0x01
|
|
74
|
+
|
|
75
|
+
// report keys (cmd 0x06)
|
|
76
|
+
static let REPORT_INPUT = 0x01
|
|
77
|
+
static let REPORT_APP = 0x02
|
|
78
|
+
static let REPORT_TWS = 0x03
|
|
79
|
+
static let REPORT_BUSINESS = 0x04
|
|
80
|
+
static let REPORT_GATT_STATE = 0x05
|
|
81
|
+
|
|
82
|
+
// business report ids
|
|
83
|
+
static let BUSINESS_HEARTBEAT = 0x03
|
|
84
|
+
static let BUSINESS_BATTERY = 0x05
|
|
85
|
+
|
|
86
|
+
// input event codes
|
|
87
|
+
static let INPUT_HEAD_UP = 0x01
|
|
88
|
+
static let INPUT_HEAD_DOWN = 0x02
|
|
89
|
+
static let INPUT_CLICK_RIGHT = 0x03
|
|
90
|
+
static let INPUT_DOUBLE_CLICK_RIGHT = 0x04
|
|
91
|
+
static let INPUT_LONG_PRESS_RIGHT = 0x05
|
|
92
|
+
static let INPUT_TOUCH_PRESS_RIGHT = 0x06
|
|
93
|
+
static let INPUT_TOUCH_RELEASE_RIGHT = 0x07
|
|
94
|
+
static let INPUT_CLICK_LEFT = 0x13
|
|
95
|
+
static let INPUT_DOUBLE_CLICK_LEFT = 0x14
|
|
96
|
+
static let INPUT_LONG_PRESS_LEFT = 0x15
|
|
97
|
+
static let INPUT_TOUCH_PRESS_LEFT = 0x16
|
|
98
|
+
static let INPUT_TOUCH_RELEASE_LEFT = 0x17
|
|
99
|
+
|
|
100
|
+
// app state report phases
|
|
101
|
+
static let STATE_ENTER = 0x01
|
|
102
|
+
static let STATE_EXIT = 0x03
|
|
103
|
+
|
|
104
|
+
// app ids
|
|
105
|
+
static let APP_ID_DASHBOARD = 0x00
|
|
106
|
+
static let APP_ID_NAV = 0x01
|
|
107
|
+
static let APP_ID_ASR_NOTE = 0x04
|
|
108
|
+
static let APP_ID_PROMPTER = 0x06
|
|
109
|
+
static let APP_ID_AI_TALK = 0x07
|
|
110
|
+
|
|
111
|
+
// enterApp modes
|
|
112
|
+
static let APP_MODE_STANDALONE = 0x00
|
|
113
|
+
|
|
114
|
+
// widget resTypes
|
|
115
|
+
static let WIDGET_TEXT_NEW = 0x00
|
|
116
|
+
static let WIDGET_PICTURE = 0x80
|
|
117
|
+
|
|
118
|
+
// navigation widget resIds (appId 0x01): mini map 0x00, arrow 0x01, turn text 0x02,
|
|
119
|
+
// status bar 0x03 (raw), tip 0x04, large map 0x05.
|
|
120
|
+
static let NAV_RES_MINI_MAP = 0x00
|
|
121
|
+
static let NAV_RES_TURN_TEXT = 0x02
|
|
122
|
+
static let NAV_RES_LARGE_MAP = 0x05
|
|
123
|
+
|
|
124
|
+
// navigation image widget sizes (hard firmware requirements; see IMAGE_PROTOCOL).
|
|
125
|
+
static let NAV_MINI_MAP_SIZE = 160
|
|
126
|
+
static let NAV_LARGE_MAP_WIDTH = 452
|
|
127
|
+
static let NAV_LARGE_MAP_HEIGHT = 170
|
|
128
|
+
|
|
129
|
+
// phone types
|
|
130
|
+
static let PHONE_TYPE_IOS = 0x01
|
|
131
|
+
|
|
132
|
+
// image header
|
|
133
|
+
static let COM_IMAGE_HEADER: UInt8 = 0x16
|
|
134
|
+
static let FORMAT_2BPP = 0x02
|
|
135
|
+
static let COMPRESSION_NONE = 0x00
|
|
136
|
+
static let COMPRESSION_ZLIB = 0x07
|
|
137
|
+
|
|
138
|
+
static let MAX_BRIGHTNESS_LEVEL = 16
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// MARK: - CRC16-CCITT
|
|
142
|
+
|
|
143
|
+
/// CRC-16/CCITT-FALSE: init 0xFFFF, poly 0x1021, no reflection, no final XOR.
|
|
144
|
+
/// Computed over the application payload (app header + data), NOT the transport header.
|
|
145
|
+
func nimoCrc16(_ data: Data) -> UInt16 {
|
|
146
|
+
var crc: UInt16 = 0xFFFF
|
|
147
|
+
for byte in data {
|
|
148
|
+
crc ^= UInt16(byte) << 8
|
|
149
|
+
for _ in 0 ..< 8 {
|
|
150
|
+
if crc & 0x8000 != 0 {
|
|
151
|
+
crc = (crc << 1) ^ 0x1021
|
|
152
|
+
} else {
|
|
153
|
+
crc = crc << 1
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
return crc
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// MARK: - Frame Codec
|
|
161
|
+
|
|
162
|
+
/// Pure bytes-in/bytes-out frame codec for the Nimo 0xBF transport.
|
|
163
|
+
///
|
|
164
|
+
/// Frame layout (little-endian):
|
|
165
|
+
/// - 8-byte transport header: [magic][status][len(2)][crc16(2)][index(2)]
|
|
166
|
+
/// - 4-byte application header (requests): [cmd][key][len(2)]
|
|
167
|
+
/// - responses/reports carry an extra status byte: [cmd][key][len(2)][status][data...]
|
|
168
|
+
enum NimoFrameCodec {
|
|
169
|
+
static func transportHeader(_ payload: Data, index: Int = 0, needsAck: Bool = true) -> Data {
|
|
170
|
+
var header = Data(count: 8)
|
|
171
|
+
header[0] = NimoProtocol.FRAME_MAGIC
|
|
172
|
+
header[1] = needsAck ? NimoProtocol.STATUS_ACK : 0
|
|
173
|
+
header[2] = UInt8(payload.count & 0xFF)
|
|
174
|
+
header[3] = UInt8((payload.count >> 8) & 0xFF)
|
|
175
|
+
let crc = nimoCrc16(payload)
|
|
176
|
+
header[4] = UInt8(crc & 0xFF)
|
|
177
|
+
header[5] = UInt8((crc >> 8) & 0xFF)
|
|
178
|
+
header[6] = UInt8(index & 0xFF)
|
|
179
|
+
header[7] = UInt8((index >> 8) & 0xFF)
|
|
180
|
+
return header
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
static func applicationHeader(cmd: Int, key: Int, payloadSize: Int) -> Data {
|
|
184
|
+
return Data([
|
|
185
|
+
UInt8(cmd & 0xFF),
|
|
186
|
+
UInt8(key & 0xFF),
|
|
187
|
+
UInt8(payloadSize & 0xFF),
|
|
188
|
+
UInt8((payloadSize >> 8) & 0xFF),
|
|
189
|
+
])
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/// One complete single frame: transport header + app header + payload.
|
|
193
|
+
static func encodeFrame(
|
|
194
|
+
cmd: Int, key: Int, payload: Data = Data(), index: Int = 0, needsAck: Bool = true
|
|
195
|
+
) -> Data {
|
|
196
|
+
let transportPayload = applicationHeader(cmd: cmd, key: key, payloadSize: payload.count) + payload
|
|
197
|
+
return transportHeader(transportPayload, index: index, needsAck: needsAck) + transportPayload
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/// Content update (cmd=0x07 key=0x04) blind-sliced into 501-byte chunks, each wrapped in its
|
|
201
|
+
/// own transport header. Index rule: last chunk = 0, others = i+1 (first = 1); single chunk = 0.
|
|
202
|
+
static func updateContentFrames(
|
|
203
|
+
appId: Int, layoutId: Int, resId: Int, resType: Int, content: Data,
|
|
204
|
+
chunkSize: Int = NimoBLE.CHUNK_SIZE
|
|
205
|
+
) -> [Data] {
|
|
206
|
+
// App header payloadSize includes the 4-byte [appId][layoutId][resId][resType] prefix.
|
|
207
|
+
let appHeader = applicationHeader(
|
|
208
|
+
cmd: NimoProtocol.CMD_CONTROL_INSTRUCTION,
|
|
209
|
+
key: NimoProtocol.CTRL_UPDATE_CONTENT,
|
|
210
|
+
payloadSize: 4 + content.count
|
|
211
|
+
)
|
|
212
|
+
var full = appHeader
|
|
213
|
+
full.append(contentsOf: [
|
|
214
|
+
UInt8(appId & 0xFF), UInt8(layoutId & 0xFF), UInt8(resId & 0xFF), UInt8(resType & 0xFF),
|
|
215
|
+
])
|
|
216
|
+
full.append(content)
|
|
217
|
+
|
|
218
|
+
let chunkCount = (full.count + chunkSize - 1) / chunkSize
|
|
219
|
+
var frames: [Data] = []
|
|
220
|
+
for i in 0 ..< chunkCount {
|
|
221
|
+
let start = i * chunkSize
|
|
222
|
+
let end = min(start + chunkSize, full.count)
|
|
223
|
+
let chunk = full.subdata(in: start ..< end)
|
|
224
|
+
let isLast = i == chunkCount - 1
|
|
225
|
+
let index = isLast ? 0 : i + 1
|
|
226
|
+
frames.append(transportHeader(chunk, index: index) + chunk)
|
|
227
|
+
}
|
|
228
|
+
return frames
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/// 15-byte image header. `originalSize` MUST be the uncompressed pixel byte count
|
|
232
|
+
/// (the glasses use it to allocate the decompression buffer).
|
|
233
|
+
static func imageHeader(
|
|
234
|
+
width: Int, height: Int, formatBpp: Int, compression: Int, originalSize: Int,
|
|
235
|
+
compressedSize: Int
|
|
236
|
+
) -> Data {
|
|
237
|
+
var p = Data(count: 15)
|
|
238
|
+
p[0] = NimoProtocol.COM_IMAGE_HEADER
|
|
239
|
+
p[1] = UInt8(width & 0xFF)
|
|
240
|
+
p[2] = UInt8((width >> 8) & 0xFF)
|
|
241
|
+
p[3] = UInt8(height & 0xFF)
|
|
242
|
+
p[4] = UInt8((height >> 8) & 0xFF)
|
|
243
|
+
p[5] = UInt8(formatBpp & 0xFF)
|
|
244
|
+
p[6] = UInt8(compression & 0xFF)
|
|
245
|
+
p[7] = UInt8(originalSize & 0xFF)
|
|
246
|
+
p[8] = UInt8((originalSize >> 8) & 0xFF)
|
|
247
|
+
p[9] = UInt8((originalSize >> 16) & 0xFF)
|
|
248
|
+
p[10] = UInt8((originalSize >> 24) & 0xFF)
|
|
249
|
+
p[11] = UInt8(compressedSize & 0xFF)
|
|
250
|
+
p[12] = UInt8((compressedSize >> 8) & 0xFF)
|
|
251
|
+
p[13] = UInt8((compressedSize >> 16) & 0xFF)
|
|
252
|
+
p[14] = UInt8((compressedSize >> 24) & 0xFF)
|
|
253
|
+
return p
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/// 9-byte device time: [year(2 LE)][month][day][hour][min][sec][week][zone]
|
|
257
|
+
/// week: Sunday=0..Saturday=6; zone: signed, 15-minute units, clamped to ±48.
|
|
258
|
+
static func encodeDeviceTime(_ date: Date = Date()) -> Data {
|
|
259
|
+
let cal = Calendar.current
|
|
260
|
+
let c = cal.dateComponents(
|
|
261
|
+
[.year, .month, .day, .hour, .minute, .second, .weekday], from: date
|
|
262
|
+
)
|
|
263
|
+
var b = Data(count: 9)
|
|
264
|
+
let year = c.year ?? 2000
|
|
265
|
+
b[0] = UInt8(year & 0xFF)
|
|
266
|
+
b[1] = UInt8((year >> 8) & 0xFF)
|
|
267
|
+
b[2] = UInt8((c.month ?? 1) & 0xFF)
|
|
268
|
+
b[3] = UInt8((c.day ?? 1) & 0xFF)
|
|
269
|
+
b[4] = UInt8((c.hour ?? 0) & 0xFF)
|
|
270
|
+
b[5] = UInt8((c.minute ?? 0) & 0xFF)
|
|
271
|
+
b[6] = UInt8((c.second ?? 0) & 0xFF)
|
|
272
|
+
// Calendar weekday: Sunday=1..Saturday=7 → protocol Sunday=0..Saturday=6
|
|
273
|
+
b[7] = UInt8(((c.weekday ?? 1) - 1) & 0xFF)
|
|
274
|
+
let offsetMinutes = TimeZone.current.secondsFromGMT(for: date) / 60
|
|
275
|
+
let zone = max(-48, min(48, offsetMinutes / 15))
|
|
276
|
+
b[8] = UInt8(bitPattern: Int8(zone))
|
|
277
|
+
return b
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/// A decoded frame. `cmd`/`key`/`statusCode`/`data` are nil when the payload is too short.
|
|
281
|
+
struct DecodedFrame {
|
|
282
|
+
let transportStatus: UInt8
|
|
283
|
+
let index: Int
|
|
284
|
+
let cmd: Int?
|
|
285
|
+
let key: Int?
|
|
286
|
+
let statusCode: Int?
|
|
287
|
+
let data: Data?
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/// Decodes one complete frame (responses and reports both use the 5-byte
|
|
291
|
+
/// [cmd][key][len(2)][status] application header). Returns nil on transport
|
|
292
|
+
/// error, bad CRC, or truncation.
|
|
293
|
+
static func decode(_ frame: Data) -> DecodedFrame? {
|
|
294
|
+
guard frame.count >= 8, frame[0] == NimoProtocol.FRAME_MAGIC else { return nil }
|
|
295
|
+
let transportStatus = frame[1]
|
|
296
|
+
let payloadLen = Int(frame[2]) | (Int(frame[3]) << 8)
|
|
297
|
+
let crcValue = UInt16(frame[4]) | (UInt16(frame[5]) << 8)
|
|
298
|
+
let index = Int(frame[6]) | (Int(frame[7]) << 8)
|
|
299
|
+
guard frame.count >= 8 + payloadLen else { return nil }
|
|
300
|
+
guard transportStatus & NimoProtocol.STATUS_ERR == 0 else { return nil }
|
|
301
|
+
|
|
302
|
+
let payload = frame.subdata(in: 8 ..< 8 + payloadLen)
|
|
303
|
+
guard nimoCrc16(payload) == crcValue else { return nil }
|
|
304
|
+
|
|
305
|
+
guard payload.count >= 5 else {
|
|
306
|
+
return DecodedFrame(
|
|
307
|
+
transportStatus: transportStatus, index: index, cmd: nil, key: nil,
|
|
308
|
+
statusCode: nil, data: nil
|
|
309
|
+
)
|
|
310
|
+
}
|
|
311
|
+
let data = payload.count > 5 ? payload.subdata(in: 5 ..< payload.count) : Data()
|
|
312
|
+
return DecodedFrame(
|
|
313
|
+
transportStatus: transportStatus,
|
|
314
|
+
index: index,
|
|
315
|
+
cmd: Int(payload[0]),
|
|
316
|
+
key: Int(payload[1]),
|
|
317
|
+
statusCode: Int(payload[4]),
|
|
318
|
+
data: data
|
|
319
|
+
)
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
// MARK: - Receive Assembler
|
|
324
|
+
|
|
325
|
+
/// Reassembles multi-packet responses. Fragments are grouped by (cmd,key); each fragment carries
|
|
326
|
+
/// the full 5-byte response app header. The first fragment (index==1) keeps its header, the
|
|
327
|
+
/// continuation fragments (index>1) and the last fragment (index==0) contribute only their data
|
|
328
|
+
/// sections, concatenated in ascending index order with the last (0) treated as largest. The
|
|
329
|
+
/// merged message is re-framed (lengths and CRC recomputed) so `NimoFrameCodec.decode` can parse
|
|
330
|
+
/// it. Single packets (index==0, no cached group) pass through unchanged.
|
|
331
|
+
class NimoReceiveAssembler {
|
|
332
|
+
private class Pending {
|
|
333
|
+
var firstAppPayload: Data?
|
|
334
|
+
var dataByIndex: [Int: Data] = [:]
|
|
335
|
+
let startTime = Date()
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
private var pending: [Int: Pending] = [:]
|
|
339
|
+
|
|
340
|
+
func ingest(_ packet: Data) -> [Data] {
|
|
341
|
+
guard packet.count >= 8 else { return [] }
|
|
342
|
+
let payloadLen = Int(packet[2]) | (Int(packet[3]) << 8)
|
|
343
|
+
let index = Int(packet[6]) | (Int(packet[7]) << 8)
|
|
344
|
+
guard packet.count >= 8 + payloadLen else { return [] }
|
|
345
|
+
let appPayload = packet.subdata(in: 8 ..< 8 + payloadLen)
|
|
346
|
+
|
|
347
|
+
guard appPayload.count >= 2 else {
|
|
348
|
+
return index == 0 ? [packet] : []
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
let cmd = Int(appPayload[0])
|
|
352
|
+
let key = Int(appPayload[1])
|
|
353
|
+
let groupKey = (cmd << 8) | key
|
|
354
|
+
|
|
355
|
+
if index == 0 {
|
|
356
|
+
guard let p = pending.removeValue(forKey: groupKey) else { return [packet] }
|
|
357
|
+
p.dataByIndex[Int.max] = dataSection(appPayload)
|
|
358
|
+
return [reframe(p)]
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
if index == 1 {
|
|
362
|
+
// A new first fragment while an old first fragment is cached means the previous
|
|
363
|
+
// round lost its last packet — drop the stale group and start over.
|
|
364
|
+
if let existing = pending[groupKey], existing.firstAppPayload != nil {
|
|
365
|
+
pending.removeValue(forKey: groupKey)
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
let p = pending[groupKey] ?? Pending()
|
|
370
|
+
pending[groupKey] = p
|
|
371
|
+
if index == 1 {
|
|
372
|
+
p.firstAppPayload = appPayload
|
|
373
|
+
}
|
|
374
|
+
p.dataByIndex[index] = dataSection(appPayload)
|
|
375
|
+
return []
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
func cleanup(timeoutSeconds: TimeInterval = 10) {
|
|
379
|
+
let now = Date()
|
|
380
|
+
pending = pending.filter { now.timeIntervalSince($0.value.startTime) < timeoutSeconds }
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
func reset() {
|
|
384
|
+
pending.removeAll()
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
private func dataSection(_ appPayload: Data) -> Data {
|
|
388
|
+
return appPayload.count <= 5 ? Data() : appPayload.subdata(in: 5 ..< appPayload.count)
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
private func reframe(_ p: Pending) -> Data {
|
|
392
|
+
var merged = Data()
|
|
393
|
+
for k in p.dataByIndex.keys.sorted() {
|
|
394
|
+
merged.append(p.dataByIndex[k]!)
|
|
395
|
+
}
|
|
396
|
+
let first = p.firstAppPayload
|
|
397
|
+
let cmd: UInt8 = (first?.count ?? 0) >= 1 ? first![0] : 0
|
|
398
|
+
let key: UInt8 = (first?.count ?? 0) >= 2 ? first![1] : 0
|
|
399
|
+
let status: UInt8 = (first?.count ?? 0) >= 5 ? first![4] : 0
|
|
400
|
+
|
|
401
|
+
var appPayload = Data([
|
|
402
|
+
cmd, key, UInt8(merged.count & 0xFF), UInt8((merged.count >> 8) & 0xFF), status,
|
|
403
|
+
])
|
|
404
|
+
appPayload.append(merged)
|
|
405
|
+
return NimoFrameCodec.transportHeader(appPayload, index: 0) + appPayload
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
// MARK: - Mic Audio Parser
|
|
410
|
+
|
|
411
|
+
/// H-T-L-V parser for the mic channel (char 2025). Packets: [0x52][type][len(2 LE)][payload].
|
|
412
|
+
/// For Opus packets the payload is [SN(2 LE)][frameCnt][reserved] then frameCnt frames of
|
|
413
|
+
/// [frameLen(1)][body] where opusLen = body[3] and the Opus bytes are body[8 .. 8+opusLen].
|
|
414
|
+
/// Slicing by opusLen (not frameLen-8) is mandatory — the tail holds CRC/padding bytes.
|
|
415
|
+
enum NimoAudioParser {
|
|
416
|
+
static let HEADER: UInt8 = 0x52
|
|
417
|
+
static let TYPE_STOP = 0x00
|
|
418
|
+
static let TYPE_START = 0x01
|
|
419
|
+
static let TYPE_OPUS_LEFT = 0x02
|
|
420
|
+
static let TYPE_OPUS_RIGHT = 0x03
|
|
421
|
+
|
|
422
|
+
struct Packet {
|
|
423
|
+
let type: Int
|
|
424
|
+
let sequence: Int
|
|
425
|
+
let opusFrames: [Data]
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
static func parse(_ data: Data) -> Packet? {
|
|
429
|
+
guard data.count >= 4, data[0] == HEADER else { return nil }
|
|
430
|
+
let type = Int(data[1])
|
|
431
|
+
let len = Int(data[2]) | (Int(data[3]) << 8)
|
|
432
|
+
guard data.count >= 4 + len else { return Packet(type: type, sequence: 0, opusFrames: []) }
|
|
433
|
+
guard type == TYPE_OPUS_LEFT || type == TYPE_OPUS_RIGHT else {
|
|
434
|
+
return Packet(type: type, sequence: 0, opusFrames: [])
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
let payload = data.subdata(in: 4 ..< 4 + len)
|
|
438
|
+
guard payload.count >= 4 else { return Packet(type: type, sequence: 0, opusFrames: []) }
|
|
439
|
+
let sn = Int(payload[0]) | (Int(payload[1]) << 8)
|
|
440
|
+
let frameCnt = Int(payload[2])
|
|
441
|
+
var offset = 4
|
|
442
|
+
var frames: [Data] = []
|
|
443
|
+
var i = 0
|
|
444
|
+
while i < frameCnt, offset < payload.count {
|
|
445
|
+
let frameLen = Int(payload[offset])
|
|
446
|
+
offset += 1
|
|
447
|
+
guard offset + frameLen <= payload.count else { break }
|
|
448
|
+
let body = payload.subdata(in: offset ..< offset + frameLen)
|
|
449
|
+
offset += frameLen
|
|
450
|
+
i += 1
|
|
451
|
+
if frameLen < 8 { continue }
|
|
452
|
+
let opusLen = Int(body[3])
|
|
453
|
+
if 8 + opusLen > frameLen { continue }
|
|
454
|
+
frames.append(body.subdata(in: 8 ..< 8 + opusLen))
|
|
455
|
+
}
|
|
456
|
+
return Packet(type: type, sequence: sn, opusFrames: frames)
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
// MARK: - Opus Decoder (AVAudioConverter)
|
|
461
|
+
|
|
462
|
+
/// Decodes the glasses' 16 kHz mono Opus frames to 16-bit PCM via the system
|
|
463
|
+
/// Opus decoder, so no native Opus library has to be vendored.
|
|
464
|
+
private class NimoOpusDecoder {
|
|
465
|
+
private let opusFormat: AVAudioFormat
|
|
466
|
+
private let pcmFormat: AVAudioFormat
|
|
467
|
+
private var converter: AVAudioConverter?
|
|
468
|
+
|
|
469
|
+
init?() {
|
|
470
|
+
var desc = AudioStreamBasicDescription(
|
|
471
|
+
mSampleRate: 16000,
|
|
472
|
+
mFormatID: kAudioFormatOpus,
|
|
473
|
+
mFormatFlags: 0,
|
|
474
|
+
mBytesPerPacket: 0,
|
|
475
|
+
mFramesPerPacket: 320, // 20 ms at 16 kHz
|
|
476
|
+
mBytesPerFrame: 0,
|
|
477
|
+
mChannelsPerFrame: 1,
|
|
478
|
+
mBitsPerChannel: 0,
|
|
479
|
+
mReserved: 0
|
|
480
|
+
)
|
|
481
|
+
guard let inFormat = AVAudioFormat(streamDescription: &desc),
|
|
482
|
+
let outFormat = AVAudioFormat(
|
|
483
|
+
commonFormat: .pcmFormatInt16, sampleRate: 16000, channels: 1, interleaved: true
|
|
484
|
+
)
|
|
485
|
+
else { return nil }
|
|
486
|
+
opusFormat = inFormat
|
|
487
|
+
pcmFormat = outFormat
|
|
488
|
+
converter = AVAudioConverter(from: inFormat, to: outFormat)
|
|
489
|
+
if converter == nil {
|
|
490
|
+
return nil
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
func decode(_ opusFrame: Data) -> Data? {
|
|
495
|
+
guard let converter, !opusFrame.isEmpty else { return nil }
|
|
496
|
+
|
|
497
|
+
let compressed = AVAudioCompressedBuffer(
|
|
498
|
+
format: opusFormat, packetCapacity: 1, maximumPacketSize: opusFrame.count
|
|
499
|
+
)
|
|
500
|
+
opusFrame.withUnsafeBytes { (raw: UnsafeRawBufferPointer) in
|
|
501
|
+
compressed.data.copyMemory(from: raw.baseAddress!, byteCount: opusFrame.count)
|
|
502
|
+
}
|
|
503
|
+
compressed.byteLength = UInt32(opusFrame.count)
|
|
504
|
+
compressed.packetCount = 1
|
|
505
|
+
compressed.packetDescriptions?.pointee = AudioStreamPacketDescription(
|
|
506
|
+
mStartOffset: 0, mVariableFramesInPacket: 0, mDataByteSize: UInt32(opusFrame.count)
|
|
507
|
+
)
|
|
508
|
+
|
|
509
|
+
// Up to 40 ms of output per frame, to be safe.
|
|
510
|
+
guard let pcmBuffer = AVAudioPCMBuffer(pcmFormat: pcmFormat, frameCapacity: 640) else {
|
|
511
|
+
return nil
|
|
512
|
+
}
|
|
513
|
+
var fed = false
|
|
514
|
+
var error: NSError?
|
|
515
|
+
let status = converter.convert(to: pcmBuffer, error: &error) { _, outStatus in
|
|
516
|
+
if fed {
|
|
517
|
+
outStatus.pointee = .noDataNow
|
|
518
|
+
return nil
|
|
519
|
+
}
|
|
520
|
+
fed = true
|
|
521
|
+
outStatus.pointee = .haveData
|
|
522
|
+
return compressed
|
|
523
|
+
}
|
|
524
|
+
if status == .error {
|
|
525
|
+
Bridge.log("NIMO: Opus decode error: \(error?.localizedDescription ?? "unknown")")
|
|
526
|
+
return nil
|
|
527
|
+
}
|
|
528
|
+
guard pcmBuffer.frameLength > 0, let channel = pcmBuffer.int16ChannelData else {
|
|
529
|
+
return nil
|
|
530
|
+
}
|
|
531
|
+
return Data(bytes: channel[0], count: Int(pcmBuffer.frameLength) * 2)
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
// MARK: - Reconnection Manager
|
|
536
|
+
|
|
537
|
+
actor NimoReconnectionManager {
|
|
538
|
+
private var task: Task<Void, Never>?
|
|
539
|
+
private let intervalSeconds: UInt64
|
|
540
|
+
|
|
541
|
+
init(intervalSeconds: UInt64 = 30) {
|
|
542
|
+
self.intervalSeconds = intervalSeconds
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
func start(_ onAttempt: @escaping () async -> Bool) {
|
|
546
|
+
stop()
|
|
547
|
+
task = Task {
|
|
548
|
+
var attempts = 0
|
|
549
|
+
while !Task.isCancelled {
|
|
550
|
+
try? await Task.sleep(nanoseconds: intervalSeconds * 1_000_000_000)
|
|
551
|
+
if Task.isCancelled { return }
|
|
552
|
+
attempts += 1
|
|
553
|
+
Bridge.log("NIMO: Reconnection attempt \(attempts)")
|
|
554
|
+
if await onAttempt() {
|
|
555
|
+
Bridge.log("NIMO: Reconnection successful, stopping")
|
|
556
|
+
return
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
func stop() {
|
|
563
|
+
task?.cancel()
|
|
564
|
+
task = nil
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
// MARK: - Nimo Class
|
|
569
|
+
|
|
570
|
+
class Nimo: NSObject, SGCManager {
|
|
571
|
+
var type: String = DeviceTypes.NIMO
|
|
572
|
+
let hasMic = true
|
|
573
|
+
|
|
574
|
+
private static let _bluetoothQueue = DispatchQueue(label: "BluetoothNimo", qos: .userInitiated)
|
|
575
|
+
|
|
576
|
+
private enum Const {
|
|
577
|
+
static let twsTimeoutSeconds: TimeInterval = 10
|
|
578
|
+
static let ackTimeoutSeconds: TimeInterval = 5
|
|
579
|
+
static let pairingTimeoutSeconds: TimeInterval = 15
|
|
580
|
+
static let batteryPollSeconds: TimeInterval = 30
|
|
581
|
+
static let textQueueTickSeconds: TimeInterval = 0.1
|
|
582
|
+
static let writeWatchdogSeconds: TimeInterval = 1
|
|
583
|
+
// Fall back to the other ear if the preferred one goes quiet for this long.
|
|
584
|
+
static let micSideFallbackSeconds: TimeInterval = 2
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
// The text surface every sendTextWall renders into. The ASR note view (appId 0x04,
|
|
588
|
+
// note text resId 0x00) is a plain text page that renders pushed text directly;
|
|
589
|
+
// Prompter (0x06) was tried first but did not display pushed text on hardware.
|
|
590
|
+
private let textAppId = NimoProtocol.APP_ID_ASR_NOTE
|
|
591
|
+
private let textLayoutId = 0
|
|
592
|
+
private let textResId = 0
|
|
593
|
+
|
|
594
|
+
// BLE
|
|
595
|
+
private var centralManager: CBCentralManager?
|
|
596
|
+
private var peripheral: CBPeripheral?
|
|
597
|
+
private var txChar: CBCharacteristic?
|
|
598
|
+
private var rxChar: CBCharacteristic?
|
|
599
|
+
private var micChar: CBCharacteristic?
|
|
600
|
+
private var isDisconnecting = false
|
|
601
|
+
|
|
602
|
+
// Device search
|
|
603
|
+
private var DEVICE_SEARCH_ID = "NOT_SET"
|
|
604
|
+
|
|
605
|
+
private var lastDeviceName: String? {
|
|
606
|
+
get { UserDefaults.standard.string(forKey: "nimo_lastDeviceName") }
|
|
607
|
+
set { UserDefaults.standard.set(newValue, forKey: "nimo_lastDeviceName") }
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
private var lastDeviceUUID: String? {
|
|
611
|
+
get { UserDefaults.standard.string(forKey: "nimo_lastDeviceUUID") }
|
|
612
|
+
set { UserDefaults.standard.set(newValue, forKey: "nimo_lastDeviceUUID") }
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
private let reconnectionManager = NimoReconnectionManager()
|
|
616
|
+
private let receiveAssembler = NimoReceiveAssembler()
|
|
617
|
+
|
|
618
|
+
// Handshake
|
|
619
|
+
private enum HandshakeState {
|
|
620
|
+
case idle
|
|
621
|
+
case awaitingTws
|
|
622
|
+
case awaitingTimeAck
|
|
623
|
+
case ready
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
private var handshakeState = HandshakeState.idle
|
|
627
|
+
private var twsConnected = false
|
|
628
|
+
private var twsTimeoutItem: DispatchWorkItem?
|
|
629
|
+
private var pairingTimeoutItem: DispatchWorkItem?
|
|
630
|
+
|
|
631
|
+
// Pending acks keyed by (cmd << 8) | key
|
|
632
|
+
private struct PendingAck {
|
|
633
|
+
let onResult: (Bool) -> Void
|
|
634
|
+
let timeoutItem: DispatchWorkItem
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
private var pendingAcks: [Int: PendingAck] = [:]
|
|
638
|
+
|
|
639
|
+
// Serialized write queue: writes use .withResponse, the next write goes out after
|
|
640
|
+
// didWriteValueFor (plus a 5 ms inter-frame delay), with a watchdog to unstick the queue.
|
|
641
|
+
private struct QueuedWrite {
|
|
642
|
+
let char: CBCharacteristic
|
|
643
|
+
let bytes: Data
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
private var writeQueue: [QueuedWrite] = []
|
|
647
|
+
private var writeInFlight = false
|
|
648
|
+
private var writeWatchdogItem: DispatchWorkItem?
|
|
649
|
+
|
|
650
|
+
// Timers
|
|
651
|
+
private var batteryPollTimer: Timer?
|
|
652
|
+
private var textQueueTimer: Timer?
|
|
653
|
+
|
|
654
|
+
// Text rendering
|
|
655
|
+
private var pendingText: String?
|
|
656
|
+
private var textAppEntered = false
|
|
657
|
+
private var navAppEntered = false
|
|
658
|
+
private var currentGlassesAppId = -1
|
|
659
|
+
|
|
660
|
+
// Battery
|
|
661
|
+
private var lastBatteryLevel = -1
|
|
662
|
+
private var lastCharging = false
|
|
663
|
+
|
|
664
|
+
// Version info
|
|
665
|
+
private var firmwareVersionPacked = ""
|
|
666
|
+
private var firmwareVersionDetail = ""
|
|
667
|
+
|
|
668
|
+
// Mic audio
|
|
669
|
+
private var opusDecoder: NimoOpusDecoder?
|
|
670
|
+
private var lastRightPacketTime = Date.distantPast
|
|
671
|
+
private var preferRightMic = true
|
|
672
|
+
|
|
673
|
+
// MARK: - SGCManager: Connection Management
|
|
674
|
+
|
|
675
|
+
func findCompatibleDevices() {
|
|
676
|
+
Bridge.log("NIMO: findCompatibleDevices()")
|
|
677
|
+
DEVICE_SEARCH_ID = "NOT_SET"
|
|
678
|
+
DeviceStore.shared.apply("glasses", "connectionState", ConnTypes.SCANNING)
|
|
679
|
+
startScan()
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
func connectById(_ id: String) {
|
|
683
|
+
Bridge.log("NIMO: connectById(\(id))")
|
|
684
|
+
DEVICE_SEARCH_ID = id
|
|
685
|
+
DeviceStore.shared.apply("glasses", "connectionState", ConnTypes.CONNECTING)
|
|
686
|
+
isDisconnecting = false
|
|
687
|
+
startPairingTimeout()
|
|
688
|
+
startScan()
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
func stopScan() {
|
|
692
|
+
centralManager?.stopScan()
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
func disconnect() {
|
|
696
|
+
Bridge.log("NIMO: disconnect()")
|
|
697
|
+
isDisconnecting = true
|
|
698
|
+
cancelPairingTimeout()
|
|
699
|
+
cancelTwsTimeout()
|
|
700
|
+
stopScan()
|
|
701
|
+
stopTimers()
|
|
702
|
+
Task { await reconnectionManager.stop() }
|
|
703
|
+
failAllPendingAcks()
|
|
704
|
+
|
|
705
|
+
if let peripheral {
|
|
706
|
+
centralManager?.cancelPeripheralConnection(peripheral)
|
|
707
|
+
}
|
|
708
|
+
peripheral = nil
|
|
709
|
+
txChar = nil
|
|
710
|
+
rxChar = nil
|
|
711
|
+
micChar = nil
|
|
712
|
+
resetSessionState()
|
|
713
|
+
|
|
714
|
+
DeviceStore.shared.apply("glasses", "connected", false)
|
|
715
|
+
DeviceStore.shared.apply("glasses", "fullyBooted", false)
|
|
716
|
+
DeviceStore.shared.apply("glasses", "connectionState", ConnTypes.DISCONNECTED)
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
func forget() {
|
|
720
|
+
Bridge.log("NIMO: forget()")
|
|
721
|
+
disconnect()
|
|
722
|
+
lastDeviceName = nil
|
|
723
|
+
lastDeviceUUID = nil
|
|
724
|
+
DEVICE_SEARCH_ID = "NOT_SET"
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
func cleanup() {
|
|
728
|
+
disconnect()
|
|
729
|
+
opusDecoder = nil
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
func getConnectedBluetoothName() -> String? {
|
|
733
|
+
return peripheral?.name
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
func ping() {
|
|
737
|
+
sendFrame(
|
|
738
|
+
NimoFrameCodec.encodeFrame(
|
|
739
|
+
cmd: NimoProtocol.CMD_GET_PARAMETER, key: NimoProtocol.GET_TWS_STATUS
|
|
740
|
+
)
|
|
741
|
+
)
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
func dbg1() {}
|
|
745
|
+
func dbg2() {}
|
|
746
|
+
func connectController() {}
|
|
747
|
+
func disconnectController() {}
|
|
748
|
+
|
|
749
|
+
// MARK: - SGCManager: Audio Control
|
|
750
|
+
|
|
751
|
+
func setMicEnabled(_ enabled: Bool) {
|
|
752
|
+
Bridge.log("NIMO: setMicEnabled(\(enabled))")
|
|
753
|
+
guard let micChar else {
|
|
754
|
+
Bridge.log("NIMO: mic characteristic not available")
|
|
755
|
+
return
|
|
756
|
+
}
|
|
757
|
+
DeviceStore.shared.apply("glasses", "micEnabled", enabled)
|
|
758
|
+
if enabled {
|
|
759
|
+
if opusDecoder == nil {
|
|
760
|
+
opusDecoder = NimoOpusDecoder()
|
|
761
|
+
if opusDecoder == nil {
|
|
762
|
+
Bridge.log("NIMO: system Opus decoder unavailable — glasses mic disabled")
|
|
763
|
+
}
|
|
764
|
+
}
|
|
765
|
+
enqueueWrite(micChar, Data([0x52, 0x01, 0x00, 0x00]))
|
|
766
|
+
} else {
|
|
767
|
+
enqueueWrite(micChar, Data([0x52, 0x00, 0x00, 0x00]))
|
|
768
|
+
}
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
func sortMicRanking(list: [String]) -> [String] {
|
|
772
|
+
return list
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
// MARK: - SGCManager: Messaging
|
|
776
|
+
|
|
777
|
+
func sendJson(_: [String: Any], wakeUp _: Bool, requireAck _: Bool) {
|
|
778
|
+
Bridge.log("NIMO: sendJson - not supported")
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
// MARK: - SGCManager: Display Control
|
|
782
|
+
|
|
783
|
+
func setBrightness(_ level: Int, autoMode: Bool) {
|
|
784
|
+
Bridge.log("NIMO: setBrightness(\(level), auto=\(autoMode))")
|
|
785
|
+
sendFrame(
|
|
786
|
+
NimoFrameCodec.encodeFrame(
|
|
787
|
+
cmd: NimoProtocol.CMD_SET_PARAMETER,
|
|
788
|
+
key: NimoProtocol.SET_AUTO_BRIGHTNESS,
|
|
789
|
+
payload: Data([autoMode ? 1 : 0])
|
|
790
|
+
)
|
|
791
|
+
)
|
|
792
|
+
if !autoMode {
|
|
793
|
+
// The firmware takes a 0–16 level, not a 0–100 percent.
|
|
794
|
+
let clamped = max(0, min(100, level))
|
|
795
|
+
let lvl = Int((Double(clamped) / 100.0 * Double(NimoProtocol.MAX_BRIGHTNESS_LEVEL)).rounded())
|
|
796
|
+
sendFrame(
|
|
797
|
+
NimoFrameCodec.encodeFrame(
|
|
798
|
+
cmd: NimoProtocol.CMD_SET_PARAMETER,
|
|
799
|
+
key: NimoProtocol.SET_BRIGHTNESS,
|
|
800
|
+
payload: Data([UInt8(lvl)])
|
|
801
|
+
)
|
|
802
|
+
)
|
|
803
|
+
}
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
func clearDisplay() {
|
|
807
|
+
// Keep the text app/page alive — a blank update avoids re-entering the app next time.
|
|
808
|
+
pendingText = " "
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
func sendText(_ text: String) async {
|
|
812
|
+
await sendTextWall(text)
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
func sendTextWall(_ text: String) async {
|
|
816
|
+
// Coalesced: only the most recent pending text survives until the next 100ms drain.
|
|
817
|
+
pendingText = text
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
func sendDoubleTextWall(_ top: String, _ bottom: String) async {
|
|
821
|
+
await sendTextWall(top + "\n\n" + bottom)
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
func sendPositionedText(
|
|
825
|
+
_ text: String, x _: Int32, y _: Int32, width _: Int32, height _: Int32,
|
|
826
|
+
borderWidth _: Int32, borderRadius _: Int32
|
|
827
|
+
) async {
|
|
828
|
+
// Navigation pushes turn text via positioned_text. Nimo widgets have fixed geometry, so
|
|
829
|
+
// position/border are ignored — funnel the text through the same coalesced path as
|
|
830
|
+
// sendTextWall so it renders on the ASR note page. Without this override the base no-op
|
|
831
|
+
// silently dropped all navigation text.
|
|
832
|
+
// Bridge.log("NIMO: sendPositionedText(text=\(text))")
|
|
833
|
+
// pendingText = text
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
func displayBitmap(
|
|
837
|
+
base64ImageData: String, x _: Int32?, y _: Int32?, width _: Int32?, height _: Int32?
|
|
838
|
+
) async -> Bool {
|
|
839
|
+
// Nimo widgets have fixed geometry; x/y are not honored. Renders into the navigation
|
|
840
|
+
// large-map widget (appId 0x01, resId 0x05, 452x170 2bpp) — the full-width nav map,
|
|
841
|
+
// which shows far more than the small 160x160 mini-map (resId 0x00). bitmapToGrayscale
|
|
842
|
+
// aspect-fits onto black, so a non-matching source aspect letterboxes rather than
|
|
843
|
+
// distorts. The nav app must be foregrounded before pushing content (mirrors the text
|
|
844
|
+
// path). TODO: hardware-verify the large-map widget renders and the nav app-mode.
|
|
845
|
+
let targetWidth = NimoProtocol.NAV_LARGE_MAP_WIDTH
|
|
846
|
+
let targetHeight = NimoProtocol.NAV_LARGE_MAP_HEIGHT
|
|
847
|
+
Bridge.log("NIMO: displayBitmap → nav large map (navAppEntered=\(navAppEntered))")
|
|
848
|
+
guard let imageData = Data(base64Encoded: base64ImageData),
|
|
849
|
+
let image = UIImage(data: imageData)
|
|
850
|
+
else {
|
|
851
|
+
Bridge.log("NIMO: displayBitmap — could not decode image")
|
|
852
|
+
return false
|
|
853
|
+
}
|
|
854
|
+
guard let grayscale = bitmapToGrayscale(image, width: targetWidth, height: targetHeight)
|
|
855
|
+
else { return false }
|
|
856
|
+
let packed = packL8To2bpp(grayscale)
|
|
857
|
+
let (payload, compression) = compressAdaptive(packed)
|
|
858
|
+
if !navAppEntered {
|
|
859
|
+
sendFrame(
|
|
860
|
+
NimoFrameCodec.encodeFrame(
|
|
861
|
+
cmd: NimoProtocol.CMD_CONTROL_INSTRUCTION,
|
|
862
|
+
key: NimoProtocol.CTRL_ENTER_APP,
|
|
863
|
+
payload: Data([UInt8(NimoProtocol.APP_ID_NAV), UInt8(NimoProtocol.APP_MODE_STANDALONE)])
|
|
864
|
+
)
|
|
865
|
+
)
|
|
866
|
+
// Optimistic; corrected by app-state reports if the glasses refuse/exit.
|
|
867
|
+
navAppEntered = true
|
|
868
|
+
}
|
|
869
|
+
let content =
|
|
870
|
+
NimoFrameCodec.imageHeader(
|
|
871
|
+
width: targetWidth,
|
|
872
|
+
height: targetHeight,
|
|
873
|
+
formatBpp: NimoProtocol.FORMAT_2BPP,
|
|
874
|
+
compression: compression,
|
|
875
|
+
originalSize: packed.count,
|
|
876
|
+
compressedSize: compression == NimoProtocol.COMPRESSION_NONE ? 0 : payload.count
|
|
877
|
+
) + payload
|
|
878
|
+
let frames = NimoFrameCodec.updateContentFrames(
|
|
879
|
+
appId: NimoProtocol.APP_ID_NAV,
|
|
880
|
+
layoutId: 0,
|
|
881
|
+
resId: NimoProtocol.NAV_RES_LARGE_MAP,
|
|
882
|
+
resType: NimoProtocol.WIDGET_PICTURE,
|
|
883
|
+
content: content
|
|
884
|
+
)
|
|
885
|
+
enqueueFrames(frames)
|
|
886
|
+
return true
|
|
887
|
+
}
|
|
888
|
+
|
|
889
|
+
func showDashboard() {
|
|
890
|
+
Bridge.log("NIMO: showDashboard()")
|
|
891
|
+
textAppEntered = false
|
|
892
|
+
navAppEntered = false
|
|
893
|
+
sendFrame(
|
|
894
|
+
NimoFrameCodec.encodeFrame(
|
|
895
|
+
cmd: NimoProtocol.CMD_CONTROL_INSTRUCTION,
|
|
896
|
+
key: NimoProtocol.CTRL_ENTER_APP,
|
|
897
|
+
payload: Data([
|
|
898
|
+
UInt8(NimoProtocol.APP_ID_DASHBOARD), UInt8(NimoProtocol.APP_MODE_STANDALONE),
|
|
899
|
+
])
|
|
900
|
+
)
|
|
901
|
+
)
|
|
902
|
+
}
|
|
903
|
+
|
|
904
|
+
func setDashboardPosition(_ height: Int, _ depth: Int) {
|
|
905
|
+
Bridge.log("NIMO: setDashboardPosition(\(height), \(depth))")
|
|
906
|
+
sendFrame(
|
|
907
|
+
NimoFrameCodec.encodeFrame(
|
|
908
|
+
cmd: NimoProtocol.CMD_SET_PARAMETER,
|
|
909
|
+
key: NimoProtocol.SET_HEIGHT_LEVEL,
|
|
910
|
+
payload: Data([UInt8(max(0, min(10, height)))])
|
|
911
|
+
)
|
|
912
|
+
)
|
|
913
|
+
sendFrame(
|
|
914
|
+
NimoFrameCodec.encodeFrame(
|
|
915
|
+
cmd: NimoProtocol.CMD_SET_PARAMETER,
|
|
916
|
+
key: NimoProtocol.SET_DISTANCE,
|
|
917
|
+
payload: Data([UInt8(max(0, min(10, depth)))])
|
|
918
|
+
)
|
|
919
|
+
)
|
|
920
|
+
}
|
|
921
|
+
|
|
922
|
+
// MARK: - SGCManager: Device Control
|
|
923
|
+
|
|
924
|
+
func setHeadUpAngle(_ angle: Int) {
|
|
925
|
+
let clamped = max(0, min(90, angle))
|
|
926
|
+
Bridge.log("NIMO: setHeadUpAngle(\(clamped))")
|
|
927
|
+
// Enable the head-up display gesture, then set the wake angle.
|
|
928
|
+
sendFrame(
|
|
929
|
+
NimoFrameCodec.encodeFrame(
|
|
930
|
+
cmd: NimoProtocol.CMD_SET_PARAMETER,
|
|
931
|
+
key: NimoProtocol.SET_HEADUP_DISPLAY,
|
|
932
|
+
payload: Data([1])
|
|
933
|
+
)
|
|
934
|
+
)
|
|
935
|
+
// setAngle payload is [optType, deg]. TODO: hardware-verify optType semantics.
|
|
936
|
+
sendFrame(
|
|
937
|
+
NimoFrameCodec.encodeFrame(
|
|
938
|
+
cmd: NimoProtocol.CMD_SET_PARAMETER,
|
|
939
|
+
key: NimoProtocol.SET_ANGLE,
|
|
940
|
+
payload: Data([0x01, UInt8(clamped)])
|
|
941
|
+
)
|
|
942
|
+
)
|
|
943
|
+
}
|
|
944
|
+
|
|
945
|
+
func getBatteryStatus() {
|
|
946
|
+
sendFrame(
|
|
947
|
+
NimoFrameCodec.encodeFrame(
|
|
948
|
+
cmd: NimoProtocol.CMD_GET_PARAMETER, key: NimoProtocol.GET_BATTERY
|
|
949
|
+
)
|
|
950
|
+
)
|
|
951
|
+
}
|
|
952
|
+
|
|
953
|
+
func setSilentMode(_ enabled: Bool) {
|
|
954
|
+
Bridge.log("NIMO: setSilentMode(\(enabled))")
|
|
955
|
+
// TODO: hardware-verify that display-off matches MentraOS silent-mode semantics.
|
|
956
|
+
sendFrame(
|
|
957
|
+
NimoFrameCodec.encodeFrame(
|
|
958
|
+
cmd: NimoProtocol.CMD_SET_PARAMETER,
|
|
959
|
+
key: NimoProtocol.SET_DISPLAY_OFF,
|
|
960
|
+
payload: Data([enabled ? 1 : 0])
|
|
961
|
+
)
|
|
962
|
+
)
|
|
963
|
+
}
|
|
964
|
+
|
|
965
|
+
func exit() {
|
|
966
|
+
Bridge.log("NIMO: exit()")
|
|
967
|
+
let appId = currentGlassesAppId >= 0 ? currentGlassesAppId : textAppId
|
|
968
|
+
textAppEntered = false
|
|
969
|
+
navAppEntered = false
|
|
970
|
+
sendFrame(
|
|
971
|
+
NimoFrameCodec.encodeFrame(
|
|
972
|
+
cmd: NimoProtocol.CMD_CONTROL_INSTRUCTION,
|
|
973
|
+
key: NimoProtocol.CTRL_QUIT_APP,
|
|
974
|
+
payload: Data([UInt8(appId & 0xFF)])
|
|
975
|
+
)
|
|
976
|
+
)
|
|
977
|
+
}
|
|
978
|
+
|
|
979
|
+
func sendShutdown() {
|
|
980
|
+
Bridge.log("NIMO: sendShutdown - not supported")
|
|
981
|
+
}
|
|
982
|
+
|
|
983
|
+
func sendReboot() {
|
|
984
|
+
Bridge.log("NIMO: sendReboot - not supported")
|
|
985
|
+
}
|
|
986
|
+
|
|
987
|
+
func sendRgbLedControl(
|
|
988
|
+
requestId: String, packageName _: String?, action _: String, color _: String?,
|
|
989
|
+
onDurationMs _: Int, offDurationMs _: Int, count _: Int
|
|
990
|
+
) {
|
|
991
|
+
Bridge.sendRgbLedControlResponse(requestId: requestId, success: false, error: "device_not_supported")
|
|
992
|
+
}
|
|
993
|
+
|
|
994
|
+
// MARK: - Notifications
|
|
995
|
+
|
|
996
|
+
/// Pushes a notification (cmd=0x09 key=0x01, fixed 319-byte struct:
|
|
997
|
+
/// appId(1) + title(51, UTF-8 zero-padded) + time(9) + contentLen(2 LE) + content(256)).
|
|
998
|
+
func sendNotification(notificationAppId: Int, title: String, content: String) {
|
|
999
|
+
var payload = Data(count: 319)
|
|
1000
|
+
payload[0] = UInt8(max(0, min(255, notificationAppId)))
|
|
1001
|
+
writeUtf8Padded(&payload, offset: 1, text: title, maxLen: 51)
|
|
1002
|
+
let time = NimoFrameCodec.encodeDeviceTime()
|
|
1003
|
+
payload.replaceSubrange(52 ..< 61, with: time)
|
|
1004
|
+
let contentBytes = Data(content.utf8)
|
|
1005
|
+
let clen = min(contentBytes.count, 256)
|
|
1006
|
+
payload[61] = UInt8(clen & 0xFF)
|
|
1007
|
+
payload[62] = UInt8((clen >> 8) & 0xFF)
|
|
1008
|
+
writeUtf8Padded(&payload, offset: 63, text: content, maxLen: 256)
|
|
1009
|
+
sendFrame(
|
|
1010
|
+
NimoFrameCodec.encodeFrame(
|
|
1011
|
+
cmd: NimoProtocol.CMD_CONTROL_NOTIFICATION,
|
|
1012
|
+
key: NimoProtocol.NOTIFICATION_SEND,
|
|
1013
|
+
payload: payload
|
|
1014
|
+
)
|
|
1015
|
+
)
|
|
1016
|
+
}
|
|
1017
|
+
|
|
1018
|
+
private func writeUtf8Padded(_ dest: inout Data, offset: Int, text: String, maxLen: Int) {
|
|
1019
|
+
var bytes = Data(text.utf8)
|
|
1020
|
+
if bytes.count > maxLen {
|
|
1021
|
+
// Truncate on a UTF-8 boundary
|
|
1022
|
+
var end = maxLen
|
|
1023
|
+
while end > 0, bytes[end] & 0xC0 == 0x80 { end -= 1 }
|
|
1024
|
+
bytes = bytes.subdata(in: 0 ..< end)
|
|
1025
|
+
}
|
|
1026
|
+
dest.replaceSubrange(offset ..< offset + bytes.count, with: bytes)
|
|
1027
|
+
}
|
|
1028
|
+
|
|
1029
|
+
// MARK: - SGCManager: Camera & Media (no camera)
|
|
1030
|
+
|
|
1031
|
+
func requestPhoto(
|
|
1032
|
+
_: String, appId _: String, size _: String?, webhookUrl _: String?, authToken _: String?,
|
|
1033
|
+
compress _: String?, flash _: Bool, save _: Bool, sound _: Bool, exposureTimeNs _: Double?,
|
|
1034
|
+
iso _: Int?
|
|
1035
|
+
) {
|
|
1036
|
+
Bridge.log("NIMO: requestPhoto - not supported (no camera)")
|
|
1037
|
+
}
|
|
1038
|
+
|
|
1039
|
+
func requestPhoto(_: PhotoRequest) {
|
|
1040
|
+
Bridge.log("NIMO: requestPhoto(PhotoRequest) - not supported (no camera)")
|
|
1041
|
+
}
|
|
1042
|
+
|
|
1043
|
+
func startStream(_: [String: Any]) {
|
|
1044
|
+
Bridge.log("NIMO: startStream - not supported")
|
|
1045
|
+
}
|
|
1046
|
+
|
|
1047
|
+
func stopStream() {
|
|
1048
|
+
Bridge.log("NIMO: stopStream - not supported")
|
|
1049
|
+
}
|
|
1050
|
+
|
|
1051
|
+
func sendStreamKeepAlive(_: [String: Any]) {}
|
|
1052
|
+
|
|
1053
|
+
func startVideoRecording(requestId _: String, save _: Bool, sound _: Bool) {
|
|
1054
|
+
Bridge.log("NIMO: startVideoRecording - not supported")
|
|
1055
|
+
}
|
|
1056
|
+
|
|
1057
|
+
func stopVideoRecording(requestId _: String) {
|
|
1058
|
+
Bridge.log("NIMO: stopVideoRecording - not supported")
|
|
1059
|
+
}
|
|
1060
|
+
|
|
1061
|
+
func sendButtonPhotoSettings() {}
|
|
1062
|
+
func sendButtonVideoRecordingSettings() {}
|
|
1063
|
+
func sendButtonMaxRecordingTime() {}
|
|
1064
|
+
func sendButtonCameraLedSetting() {}
|
|
1065
|
+
func sendCameraFovSetting() {}
|
|
1066
|
+
|
|
1067
|
+
// MARK: - SGCManager: Network (no WiFi)
|
|
1068
|
+
|
|
1069
|
+
func requestWifiScan() {}
|
|
1070
|
+
func sendWifiCredentials(_: String, _: String) {}
|
|
1071
|
+
func forgetWifiNetwork(_: String) {}
|
|
1072
|
+
func sendHotspotState(_: Bool) {}
|
|
1073
|
+
func sendOtaStart(otaVersionUrl: String?) {}
|
|
1074
|
+
func sendOtaQueryStatus() {}
|
|
1075
|
+
|
|
1076
|
+
// MARK: - SGCManager: User Context / Gallery / Version
|
|
1077
|
+
|
|
1078
|
+
func sendUserEmailToGlasses(_: String) {}
|
|
1079
|
+
func sendIncidentId(_: String, apiBaseUrl _: String?) {}
|
|
1080
|
+
func queryGalleryStatus() {}
|
|
1081
|
+
func sendGalleryMode() {}
|
|
1082
|
+
|
|
1083
|
+
func requestVersionInfo() {
|
|
1084
|
+
sendFrame(
|
|
1085
|
+
NimoFrameCodec.encodeFrame(
|
|
1086
|
+
cmd: NimoProtocol.CMD_GET_PARAMETER, key: NimoProtocol.GET_VERSION
|
|
1087
|
+
)
|
|
1088
|
+
)
|
|
1089
|
+
sendFrame(
|
|
1090
|
+
NimoFrameCodec.encodeFrame(
|
|
1091
|
+
cmd: NimoProtocol.CMD_GET_PARAMETER, key: NimoProtocol.GET_VERSION_DETAIL
|
|
1092
|
+
)
|
|
1093
|
+
)
|
|
1094
|
+
}
|
|
1095
|
+
|
|
1096
|
+
// MARK: - BLE Scanning
|
|
1097
|
+
|
|
1098
|
+
private func isNimoMainDevice(_ name: String) -> Bool {
|
|
1099
|
+
let lower = name.lowercased()
|
|
1100
|
+
return lower.hasPrefix(NimoBLE.NAME_PREFIX) && !lower.hasSuffix(NimoBLE.BLE_NAME_SUFFIX)
|
|
1101
|
+
}
|
|
1102
|
+
|
|
1103
|
+
@discardableResult
|
|
1104
|
+
private func startScan() -> Bool {
|
|
1105
|
+
Bridge.log("NIMO: startScan()")
|
|
1106
|
+
if centralManager == nil {
|
|
1107
|
+
centralManager = CBCentralManager(
|
|
1108
|
+
delegate: self, queue: Nimo._bluetoothQueue,
|
|
1109
|
+
options: [CBCentralManagerOptionShowPowerAlertKey: 0]
|
|
1110
|
+
)
|
|
1111
|
+
}
|
|
1112
|
+
guard centralManager!.state == .poweredOn else {
|
|
1113
|
+
// centralManagerDidUpdateState retries the scan once the radio is on.
|
|
1114
|
+
Bridge.log("NIMO: Bluetooth not powered on yet")
|
|
1115
|
+
return false
|
|
1116
|
+
}
|
|
1117
|
+
|
|
1118
|
+
// Fast path: reconnect to the cached peripheral UUID.
|
|
1119
|
+
if connectByUUID() {
|
|
1120
|
+
return true
|
|
1121
|
+
}
|
|
1122
|
+
|
|
1123
|
+
centralManager!.scanForPeripherals(
|
|
1124
|
+
withServices: nil,
|
|
1125
|
+
options: [CBCentralManagerScanOptionAllowDuplicatesKey: false]
|
|
1126
|
+
)
|
|
1127
|
+
return true
|
|
1128
|
+
}
|
|
1129
|
+
|
|
1130
|
+
private func connectByUUID() -> Bool {
|
|
1131
|
+
guard DEVICE_SEARCH_ID != "NOT_SET", !DEVICE_SEARCH_ID.isEmpty else { return false }
|
|
1132
|
+
guard lastDeviceName == DEVICE_SEARCH_ID,
|
|
1133
|
+
let uuidString = lastDeviceUUID,
|
|
1134
|
+
let uuid = UUID(uuidString: uuidString)
|
|
1135
|
+
else { return false }
|
|
1136
|
+
guard let known = centralManager?.retrievePeripherals(withIdentifiers: [uuid]).first
|
|
1137
|
+
else { return false }
|
|
1138
|
+
|
|
1139
|
+
Bridge.log("NIMO: connectByUUID - \(known.name ?? uuidString)")
|
|
1140
|
+
peripheral = known
|
|
1141
|
+
known.delegate = self
|
|
1142
|
+
centralManager?.connect(known, options: nil)
|
|
1143
|
+
return true
|
|
1144
|
+
}
|
|
1145
|
+
|
|
1146
|
+
private func startPairingTimeout() {
|
|
1147
|
+
cancelPairingTimeout()
|
|
1148
|
+
let item = DispatchWorkItem { [weak self] in
|
|
1149
|
+
guard let self else { return }
|
|
1150
|
+
if !self.fullyBooted {
|
|
1151
|
+
Bridge.log("NIMO: pairing timeout — handshake never completed")
|
|
1152
|
+
Bridge.sendPairFailureEvent("errors:pairNeedDisconnect")
|
|
1153
|
+
}
|
|
1154
|
+
}
|
|
1155
|
+
pairingTimeoutItem = item
|
|
1156
|
+
DispatchQueue.main.asyncAfter(deadline: .now() + Const.pairingTimeoutSeconds, execute: item)
|
|
1157
|
+
}
|
|
1158
|
+
|
|
1159
|
+
private func cancelPairingTimeout() {
|
|
1160
|
+
pairingTimeoutItem?.cancel()
|
|
1161
|
+
pairingTimeoutItem = nil
|
|
1162
|
+
}
|
|
1163
|
+
|
|
1164
|
+
// MARK: - Write Queue
|
|
1165
|
+
|
|
1166
|
+
private func drainWriteQueue() {
|
|
1167
|
+
if writeInFlight { return }
|
|
1168
|
+
guard !writeQueue.isEmpty else { return }
|
|
1169
|
+
guard let peripheral else {
|
|
1170
|
+
writeQueue.removeAll()
|
|
1171
|
+
return
|
|
1172
|
+
}
|
|
1173
|
+
let item = writeQueue.removeFirst()
|
|
1174
|
+
writeInFlight = true
|
|
1175
|
+
peripheral.writeValue(item.bytes, for: item.char, type: .withResponse)
|
|
1176
|
+
|
|
1177
|
+
// Watchdog: if the write callback never arrives, unblock the queue.
|
|
1178
|
+
writeWatchdogItem?.cancel()
|
|
1179
|
+
let watchdog = DispatchWorkItem { [weak self] in
|
|
1180
|
+
guard let self else { return }
|
|
1181
|
+
if self.writeInFlight {
|
|
1182
|
+
Bridge.log("NIMO: write watchdog fired — forcing queue drain")
|
|
1183
|
+
self.writeInFlight = false
|
|
1184
|
+
self.drainWriteQueue()
|
|
1185
|
+
}
|
|
1186
|
+
}
|
|
1187
|
+
writeWatchdogItem = watchdog
|
|
1188
|
+
DispatchQueue.main.asyncAfter(deadline: .now() + Const.writeWatchdogSeconds, execute: watchdog)
|
|
1189
|
+
}
|
|
1190
|
+
|
|
1191
|
+
private func onWriteCompleted() {
|
|
1192
|
+
DispatchQueue.main.asyncAfter(
|
|
1193
|
+
deadline: .now() + .milliseconds(NimoBLE.INTER_FRAME_DELAY_MS)
|
|
1194
|
+
) { [weak self] in
|
|
1195
|
+
guard let self else { return }
|
|
1196
|
+
self.writeInFlight = false
|
|
1197
|
+
self.drainWriteQueue()
|
|
1198
|
+
}
|
|
1199
|
+
}
|
|
1200
|
+
|
|
1201
|
+
private func enqueueWrite(_ char: CBCharacteristic, _ bytes: Data) {
|
|
1202
|
+
writeQueue.append(QueuedWrite(char: char, bytes: bytes))
|
|
1203
|
+
drainWriteQueue()
|
|
1204
|
+
}
|
|
1205
|
+
|
|
1206
|
+
private func sendFrame(_ frame: Data) {
|
|
1207
|
+
guard let txChar else { return }
|
|
1208
|
+
enqueueWrite(txChar, frame)
|
|
1209
|
+
}
|
|
1210
|
+
|
|
1211
|
+
private func enqueueFrames(_ frames: [Data]) {
|
|
1212
|
+
guard let txChar else { return }
|
|
1213
|
+
for frame in frames {
|
|
1214
|
+
writeQueue.append(QueuedWrite(char: txChar, bytes: frame))
|
|
1215
|
+
}
|
|
1216
|
+
drainWriteQueue()
|
|
1217
|
+
}
|
|
1218
|
+
|
|
1219
|
+
// MARK: - Pending ACKs
|
|
1220
|
+
|
|
1221
|
+
private func sendAwaitingAck(
|
|
1222
|
+
cmd: Int, key: Int, payload: Data, onResult: @escaping (Bool) -> Void
|
|
1223
|
+
) {
|
|
1224
|
+
let ackKey = (cmd << 8) | key
|
|
1225
|
+
// Only one in-flight ack per (cmd,key); fail any previous waiter.
|
|
1226
|
+
if let previous = pendingAcks.removeValue(forKey: ackKey) {
|
|
1227
|
+
previous.timeoutItem.cancel()
|
|
1228
|
+
previous.onResult(false)
|
|
1229
|
+
}
|
|
1230
|
+
let timeoutItem = DispatchWorkItem { [weak self] in
|
|
1231
|
+
guard let self else { return }
|
|
1232
|
+
self.pendingAcks.removeValue(forKey: ackKey)?.onResult(false)
|
|
1233
|
+
}
|
|
1234
|
+
pendingAcks[ackKey] = PendingAck(onResult: onResult, timeoutItem: timeoutItem)
|
|
1235
|
+
DispatchQueue.main.asyncAfter(deadline: .now() + Const.ackTimeoutSeconds, execute: timeoutItem)
|
|
1236
|
+
sendFrame(NimoFrameCodec.encodeFrame(cmd: cmd, key: key, payload: payload, needsAck: true))
|
|
1237
|
+
}
|
|
1238
|
+
|
|
1239
|
+
private func resolvePendingAck(cmd: Int, key: Int, success: Bool) {
|
|
1240
|
+
let ackKey = (cmd << 8) | key
|
|
1241
|
+
if let ack = pendingAcks.removeValue(forKey: ackKey) {
|
|
1242
|
+
ack.timeoutItem.cancel()
|
|
1243
|
+
ack.onResult(success)
|
|
1244
|
+
}
|
|
1245
|
+
}
|
|
1246
|
+
|
|
1247
|
+
private func failAllPendingAcks() {
|
|
1248
|
+
let acks = pendingAcks.values
|
|
1249
|
+
pendingAcks.removeAll()
|
|
1250
|
+
for ack in acks {
|
|
1251
|
+
ack.timeoutItem.cancel()
|
|
1252
|
+
ack.onResult(false)
|
|
1253
|
+
}
|
|
1254
|
+
}
|
|
1255
|
+
|
|
1256
|
+
// MARK: - Handshake
|
|
1257
|
+
|
|
1258
|
+
private func startHandshake() {
|
|
1259
|
+
Bridge.log("NIMO: starting handshake (awaiting TWS service-connection state)")
|
|
1260
|
+
handshakeState = .awaitingTws
|
|
1261
|
+
if twsConnected {
|
|
1262
|
+
proceedToTimeSync()
|
|
1263
|
+
return
|
|
1264
|
+
}
|
|
1265
|
+
cancelTwsTimeout()
|
|
1266
|
+
let item = DispatchWorkItem { [weak self] in
|
|
1267
|
+
guard let self else { return }
|
|
1268
|
+
if self.handshakeState == .awaitingTws {
|
|
1269
|
+
Bridge.log("NIMO: TWS state timeout — handshake failed, will reconnect")
|
|
1270
|
+
self.handshakeFailed()
|
|
1271
|
+
}
|
|
1272
|
+
}
|
|
1273
|
+
twsTimeoutItem = item
|
|
1274
|
+
DispatchQueue.main.asyncAfter(deadline: .now() + Const.twsTimeoutSeconds, execute: item)
|
|
1275
|
+
// Also actively query in case the glasses don't push the report unprompted.
|
|
1276
|
+
ping()
|
|
1277
|
+
}
|
|
1278
|
+
|
|
1279
|
+
private func cancelTwsTimeout() {
|
|
1280
|
+
twsTimeoutItem?.cancel()
|
|
1281
|
+
twsTimeoutItem = nil
|
|
1282
|
+
}
|
|
1283
|
+
|
|
1284
|
+
private func proceedToTimeSync() {
|
|
1285
|
+
guard handshakeState == .awaitingTws else { return }
|
|
1286
|
+
cancelTwsTimeout()
|
|
1287
|
+
handshakeState = .awaitingTimeAck
|
|
1288
|
+
Bridge.log("NIMO: TWS OK — sending setTime (awaiting ACK)")
|
|
1289
|
+
sendAwaitingAck(
|
|
1290
|
+
cmd: NimoProtocol.CMD_SET_PARAMETER,
|
|
1291
|
+
key: NimoProtocol.SET_TIME,
|
|
1292
|
+
payload: NimoFrameCodec.encodeDeviceTime()
|
|
1293
|
+
) { [weak self] ok in
|
|
1294
|
+
guard let self else { return }
|
|
1295
|
+
if !ok {
|
|
1296
|
+
Bridge.log("NIMO: setTime ACK failed/timed out — handshake failed")
|
|
1297
|
+
self.handshakeFailed()
|
|
1298
|
+
} else {
|
|
1299
|
+
self.finishHandshake()
|
|
1300
|
+
}
|
|
1301
|
+
}
|
|
1302
|
+
}
|
|
1303
|
+
|
|
1304
|
+
private func finishHandshake() {
|
|
1305
|
+
Bridge.log("NIMO: handshake complete — fully connected")
|
|
1306
|
+
handshakeState = .ready
|
|
1307
|
+
sendFrame(
|
|
1308
|
+
NimoFrameCodec.encodeFrame(
|
|
1309
|
+
cmd: NimoProtocol.CMD_SET_PARAMETER,
|
|
1310
|
+
key: NimoProtocol.SET_PHONE_TYPE,
|
|
1311
|
+
payload: Data([UInt8(NimoProtocol.PHONE_TYPE_IOS)]),
|
|
1312
|
+
needsAck: false
|
|
1313
|
+
)
|
|
1314
|
+
)
|
|
1315
|
+
getBatteryStatus()
|
|
1316
|
+
requestVersionInfo()
|
|
1317
|
+
|
|
1318
|
+
cancelPairingTimeout()
|
|
1319
|
+
Task { await reconnectionManager.stop() }
|
|
1320
|
+
DeviceStore.shared.apply("glasses", "connected", true)
|
|
1321
|
+
DeviceStore.shared.apply("glasses", "fullyBooted", true)
|
|
1322
|
+
DeviceStore.shared.apply("glasses", "connectionState", ConnTypes.CONNECTED)
|
|
1323
|
+
startTimers()
|
|
1324
|
+
}
|
|
1325
|
+
|
|
1326
|
+
private func handshakeFailed() {
|
|
1327
|
+
handshakeState = .idle
|
|
1328
|
+
if let peripheral {
|
|
1329
|
+
centralManager?.cancelPeripheralConnection(peripheral)
|
|
1330
|
+
}
|
|
1331
|
+
// The didDisconnectPeripheral path handles cleanup + reconnection.
|
|
1332
|
+
}
|
|
1333
|
+
|
|
1334
|
+
private func resetSessionState() {
|
|
1335
|
+
handshakeState = .idle
|
|
1336
|
+
twsConnected = false
|
|
1337
|
+
textAppEntered = false
|
|
1338
|
+
navAppEntered = false
|
|
1339
|
+
currentGlassesAppId = -1
|
|
1340
|
+
pendingText = nil
|
|
1341
|
+
receiveAssembler.reset()
|
|
1342
|
+
writeQueue.removeAll()
|
|
1343
|
+
writeInFlight = false
|
|
1344
|
+
writeWatchdogItem?.cancel()
|
|
1345
|
+
writeWatchdogItem = nil
|
|
1346
|
+
failAllPendingAcks()
|
|
1347
|
+
stopTimers()
|
|
1348
|
+
}
|
|
1349
|
+
|
|
1350
|
+
private func startReconnectionTimer() {
|
|
1351
|
+
Task {
|
|
1352
|
+
await reconnectionManager.start { [weak self] in
|
|
1353
|
+
guard let self else { return false }
|
|
1354
|
+
if await MainActor.run(body: {
|
|
1355
|
+
DeviceStore.shared.get("glasses", "fullyBooted") as? Bool ?? false
|
|
1356
|
+
}) {
|
|
1357
|
+
return true
|
|
1358
|
+
}
|
|
1359
|
+
Bridge.log("NIMO: Attempting reconnection...")
|
|
1360
|
+
await MainActor.run {
|
|
1361
|
+
self.isDisconnecting = false
|
|
1362
|
+
self.startScan()
|
|
1363
|
+
}
|
|
1364
|
+
return false
|
|
1365
|
+
}
|
|
1366
|
+
}
|
|
1367
|
+
}
|
|
1368
|
+
|
|
1369
|
+
// MARK: - Timers (battery poll keepalive + text queue)
|
|
1370
|
+
|
|
1371
|
+
private func startTimers() {
|
|
1372
|
+
stopTimers()
|
|
1373
|
+
// The battery poll doubles as a keepalive so iOS bluetoothd never sees the link
|
|
1374
|
+
// as unused and reclaims it.
|
|
1375
|
+
batteryPollTimer = Timer.scheduledTimer(
|
|
1376
|
+
withTimeInterval: Const.batteryPollSeconds, repeats: true
|
|
1377
|
+
) { [weak self] _ in
|
|
1378
|
+
Task { @MainActor in
|
|
1379
|
+
self?.getBatteryStatus()
|
|
1380
|
+
}
|
|
1381
|
+
}
|
|
1382
|
+
textQueueTimer = Timer.scheduledTimer(
|
|
1383
|
+
withTimeInterval: Const.textQueueTickSeconds, repeats: true
|
|
1384
|
+
) { [weak self] _ in
|
|
1385
|
+
Task { @MainActor in
|
|
1386
|
+
self?.drainTextQueue()
|
|
1387
|
+
}
|
|
1388
|
+
}
|
|
1389
|
+
}
|
|
1390
|
+
|
|
1391
|
+
private func stopTimers() {
|
|
1392
|
+
batteryPollTimer?.invalidate()
|
|
1393
|
+
batteryPollTimer = nil
|
|
1394
|
+
textQueueTimer?.invalidate()
|
|
1395
|
+
textQueueTimer = nil
|
|
1396
|
+
}
|
|
1397
|
+
|
|
1398
|
+
// MARK: - Text Rendering
|
|
1399
|
+
|
|
1400
|
+
private func drainTextQueue() {
|
|
1401
|
+
guard let text = pendingText, handshakeState == .ready else { return }
|
|
1402
|
+
pendingText = nil
|
|
1403
|
+
|
|
1404
|
+
// All text (text_wall / double_text_wall / reference_card / positioned_text) renders on
|
|
1405
|
+
// the ASR note page — the only text surface confirmed to render on hardware.
|
|
1406
|
+
if !textAppEntered {
|
|
1407
|
+
sendFrame(
|
|
1408
|
+
NimoFrameCodec.encodeFrame(
|
|
1409
|
+
cmd: NimoProtocol.CMD_CONTROL_INSTRUCTION,
|
|
1410
|
+
key: NimoProtocol.CTRL_ENTER_APP,
|
|
1411
|
+
payload: Data([UInt8(textAppId), UInt8(NimoProtocol.APP_MODE_STANDALONE)])
|
|
1412
|
+
)
|
|
1413
|
+
)
|
|
1414
|
+
// Optimistic; corrected by app-state reports if the glasses refuse/exit.
|
|
1415
|
+
textAppEntered = true
|
|
1416
|
+
}
|
|
1417
|
+
|
|
1418
|
+
let frames = NimoFrameCodec.updateContentFrames(
|
|
1419
|
+
appId: textAppId,
|
|
1420
|
+
layoutId: textLayoutId,
|
|
1421
|
+
resId: textResId,
|
|
1422
|
+
resType: NimoProtocol.WIDGET_TEXT_NEW,
|
|
1423
|
+
content: Data(text.utf8)
|
|
1424
|
+
)
|
|
1425
|
+
enqueueFrames(frames)
|
|
1426
|
+
}
|
|
1427
|
+
|
|
1428
|
+
// MARK: - Incoming Data
|
|
1429
|
+
|
|
1430
|
+
private func handleRxPacket(_ packet: Data) {
|
|
1431
|
+
receiveAssembler.cleanup()
|
|
1432
|
+
for frame in receiveAssembler.ingest(packet) {
|
|
1433
|
+
guard let decoded = NimoFrameCodec.decode(frame),
|
|
1434
|
+
let cmd = decoded.cmd, let key = decoded.key
|
|
1435
|
+
else { continue }
|
|
1436
|
+
if cmd == NimoProtocol.CMD_INSTRUCTION_REPORT {
|
|
1437
|
+
handleReport(key: key, data: decoded.data ?? Data())
|
|
1438
|
+
} else {
|
|
1439
|
+
handleResponse(
|
|
1440
|
+
cmd: cmd, key: key, statusCode: decoded.statusCode ?? 1,
|
|
1441
|
+
data: decoded.data ?? Data()
|
|
1442
|
+
)
|
|
1443
|
+
}
|
|
1444
|
+
}
|
|
1445
|
+
}
|
|
1446
|
+
|
|
1447
|
+
private func handleReport(key: Int, data: Data) {
|
|
1448
|
+
switch key {
|
|
1449
|
+
case NimoProtocol.REPORT_INPUT:
|
|
1450
|
+
if !data.isEmpty {
|
|
1451
|
+
handleInputEvent(Int(data[data.startIndex]))
|
|
1452
|
+
}
|
|
1453
|
+
case NimoProtocol.REPORT_APP:
|
|
1454
|
+
if data.count >= 2 {
|
|
1455
|
+
let appId = Int(data[data.startIndex])
|
|
1456
|
+
let phase = Int(data[data.startIndex + 1])
|
|
1457
|
+
Bridge.log("NIMO: app state report appId=\(appId) phase=\(phase)")
|
|
1458
|
+
switch phase {
|
|
1459
|
+
case NimoProtocol.STATE_ENTER:
|
|
1460
|
+
currentGlassesAppId = appId
|
|
1461
|
+
if appId != textAppId { textAppEntered = false }
|
|
1462
|
+
if appId != NimoProtocol.APP_ID_NAV { navAppEntered = false }
|
|
1463
|
+
case NimoProtocol.STATE_EXIT:
|
|
1464
|
+
if appId == currentGlassesAppId { currentGlassesAppId = -1 }
|
|
1465
|
+
if appId == textAppId { textAppEntered = false }
|
|
1466
|
+
if appId == NimoProtocol.APP_ID_NAV { navAppEntered = false }
|
|
1467
|
+
default:
|
|
1468
|
+
break
|
|
1469
|
+
}
|
|
1470
|
+
}
|
|
1471
|
+
case NimoProtocol.REPORT_TWS:
|
|
1472
|
+
if !data.isEmpty {
|
|
1473
|
+
onTwsState(Int(data[data.startIndex]) >= 1)
|
|
1474
|
+
}
|
|
1475
|
+
case NimoProtocol.REPORT_BUSINESS:
|
|
1476
|
+
handleBusinessReport(data)
|
|
1477
|
+
case NimoProtocol.REPORT_GATT_STATE:
|
|
1478
|
+
break
|
|
1479
|
+
default:
|
|
1480
|
+
Bridge.log("NIMO: unknown report key=\(key)")
|
|
1481
|
+
}
|
|
1482
|
+
}
|
|
1483
|
+
|
|
1484
|
+
private func handleBusinessReport(_ data: Data) {
|
|
1485
|
+
guard !data.isEmpty else { return }
|
|
1486
|
+
let bytes = [UInt8](data)
|
|
1487
|
+
let id = Int(bytes[0])
|
|
1488
|
+
let v = Array(bytes.dropFirst())
|
|
1489
|
+
switch id {
|
|
1490
|
+
case NimoProtocol.BUSINESS_HEARTBEAT:
|
|
1491
|
+
// [leftMv(2)][rightMv(2)][btSysStatus(4)][twsStatus(1)][slaveGatt(1)]
|
|
1492
|
+
if v.count >= 10 {
|
|
1493
|
+
onTwsState(Int(v[8]) >= 1)
|
|
1494
|
+
}
|
|
1495
|
+
case NimoProtocol.BUSINESS_BATTERY:
|
|
1496
|
+
if v.count >= 4 {
|
|
1497
|
+
applyBattery(
|
|
1498
|
+
left: Int(v[0]), right: Int(v[1]),
|
|
1499
|
+
leftCharging: v[2] == 1, rightCharging: v[3] == 1
|
|
1500
|
+
)
|
|
1501
|
+
}
|
|
1502
|
+
default:
|
|
1503
|
+
break
|
|
1504
|
+
}
|
|
1505
|
+
}
|
|
1506
|
+
|
|
1507
|
+
private func onTwsState(_ connected: Bool) {
|
|
1508
|
+
twsConnected = connected
|
|
1509
|
+
if connected, handshakeState == .awaitingTws {
|
|
1510
|
+
proceedToTimeSync()
|
|
1511
|
+
}
|
|
1512
|
+
if !connected, handshakeState == .ready {
|
|
1513
|
+
Bridge.log("NIMO: TWS service dropped mid-session (arm removed/off?)")
|
|
1514
|
+
}
|
|
1515
|
+
}
|
|
1516
|
+
|
|
1517
|
+
private func handleInputEvent(_ code: Int) {
|
|
1518
|
+
let timestamp = Int64(Date().timeIntervalSince1970 * 1000)
|
|
1519
|
+
switch code {
|
|
1520
|
+
case NimoProtocol.INPUT_HEAD_UP:
|
|
1521
|
+
DeviceStore.shared.apply("glasses", "headUp", true)
|
|
1522
|
+
Bridge.sendHeadUp(true)
|
|
1523
|
+
case NimoProtocol.INPUT_HEAD_DOWN:
|
|
1524
|
+
DeviceStore.shared.apply("glasses", "headUp", false)
|
|
1525
|
+
Bridge.sendHeadUp(false)
|
|
1526
|
+
case NimoProtocol.INPUT_CLICK_RIGHT, NimoProtocol.INPUT_CLICK_LEFT:
|
|
1527
|
+
Bridge.sendTouchEvent(
|
|
1528
|
+
deviceModel: DeviceTypes.NIMO, gestureName: "single_tap", timestamp: timestamp
|
|
1529
|
+
)
|
|
1530
|
+
case NimoProtocol.INPUT_DOUBLE_CLICK_RIGHT, NimoProtocol.INPUT_DOUBLE_CLICK_LEFT:
|
|
1531
|
+
Bridge.sendTouchEvent(
|
|
1532
|
+
deviceModel: DeviceTypes.NIMO, gestureName: "double_tap", timestamp: timestamp
|
|
1533
|
+
)
|
|
1534
|
+
case NimoProtocol.INPUT_LONG_PRESS_RIGHT, NimoProtocol.INPUT_LONG_PRESS_LEFT:
|
|
1535
|
+
Bridge.sendTouchEvent(
|
|
1536
|
+
deviceModel: DeviceTypes.NIMO, gestureName: "long_press", timestamp: timestamp
|
|
1537
|
+
)
|
|
1538
|
+
case NimoProtocol.INPUT_TOUCH_PRESS_RIGHT,
|
|
1539
|
+
NimoProtocol.INPUT_TOUCH_RELEASE_RIGHT,
|
|
1540
|
+
NimoProtocol.INPUT_TOUCH_PRESS_LEFT,
|
|
1541
|
+
NimoProtocol.INPUT_TOUCH_RELEASE_LEFT:
|
|
1542
|
+
// Raw press/release transitions are too chatty to forward; taps cover the UX.
|
|
1543
|
+
break
|
|
1544
|
+
default:
|
|
1545
|
+
Bridge.log("NIMO: unknown input event code=\(code)")
|
|
1546
|
+
}
|
|
1547
|
+
}
|
|
1548
|
+
|
|
1549
|
+
private func handleResponse(cmd: Int, key: Int, statusCode: Int, data: Data) {
|
|
1550
|
+
resolvePendingAck(cmd: cmd, key: key, success: statusCode == 0)
|
|
1551
|
+
|
|
1552
|
+
guard cmd == NimoProtocol.CMD_GET_PARAMETER, statusCode == 0 else { return }
|
|
1553
|
+
let bytes = [UInt8](data)
|
|
1554
|
+
switch key {
|
|
1555
|
+
case NimoProtocol.GET_BATTERY:
|
|
1556
|
+
if bytes.count >= 4 {
|
|
1557
|
+
applyBattery(
|
|
1558
|
+
left: Int(bytes[0]), right: Int(bytes[1]),
|
|
1559
|
+
leftCharging: bytes[2] == 1, rightCharging: bytes[3] == 1
|
|
1560
|
+
)
|
|
1561
|
+
}
|
|
1562
|
+
case NimoProtocol.GET_VERSION:
|
|
1563
|
+
if bytes.count >= 4 {
|
|
1564
|
+
let v =
|
|
1565
|
+
UInt32(bytes[0]) | (UInt32(bytes[1]) << 8) | (UInt32(bytes[2]) << 16)
|
|
1566
|
+
| (UInt32(bytes[3]) << 24)
|
|
1567
|
+
let major = (v >> 28) & 0xF
|
|
1568
|
+
let minor = (v >> 21) & 0x7F
|
|
1569
|
+
let micro = (v >> 12) & 0x1FF
|
|
1570
|
+
let build = v & 0xFFF
|
|
1571
|
+
firmwareVersionPacked = "\(major).\(minor).\(micro).\(build)"
|
|
1572
|
+
emitVersionInfo()
|
|
1573
|
+
}
|
|
1574
|
+
case NimoProtocol.GET_VERSION_DETAIL:
|
|
1575
|
+
var end = bytes.count
|
|
1576
|
+
while end > 0, bytes[end - 1] == 0 { end -= 1 }
|
|
1577
|
+
firmwareVersionDetail =
|
|
1578
|
+
String(bytes: bytes[0 ..< end], encoding: .utf8)?
|
|
1579
|
+
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
|
1580
|
+
emitVersionInfo()
|
|
1581
|
+
case NimoProtocol.GET_TWS_STATUS:
|
|
1582
|
+
if !bytes.isEmpty {
|
|
1583
|
+
onTwsState(Int(bytes[0]) >= 1)
|
|
1584
|
+
}
|
|
1585
|
+
default:
|
|
1586
|
+
break
|
|
1587
|
+
}
|
|
1588
|
+
}
|
|
1589
|
+
|
|
1590
|
+
private func applyBattery(left: Int, right: Int, leftCharging: Bool, rightCharging: Bool) {
|
|
1591
|
+
// Two independent arms → report the conservative (lower) level.
|
|
1592
|
+
let level = min(left, right)
|
|
1593
|
+
let charging = leftCharging || rightCharging
|
|
1594
|
+
if level != lastBatteryLevel || charging != lastCharging {
|
|
1595
|
+
lastBatteryLevel = level
|
|
1596
|
+
lastCharging = charging
|
|
1597
|
+
DeviceStore.shared.apply("glasses", "batteryLevel", level)
|
|
1598
|
+
DeviceStore.shared.apply("glasses", "charging", charging)
|
|
1599
|
+
Bridge.sendBatteryStatus(level: level, charging: charging)
|
|
1600
|
+
}
|
|
1601
|
+
}
|
|
1602
|
+
|
|
1603
|
+
private func emitVersionInfo() {
|
|
1604
|
+
let version = firmwareVersionDetail.isEmpty ? firmwareVersionPacked : firmwareVersionDetail
|
|
1605
|
+
guard !version.isEmpty else { return }
|
|
1606
|
+
DeviceStore.shared.apply("glasses", "firmwareVersion", version)
|
|
1607
|
+
Bridge.sendVersionInfo(["firmwareVersion": version])
|
|
1608
|
+
}
|
|
1609
|
+
|
|
1610
|
+
// MARK: - Mic Audio
|
|
1611
|
+
|
|
1612
|
+
private func handleMicPacket(_ data: Data) {
|
|
1613
|
+
guard let packet = NimoAudioParser.parse(data) else { return }
|
|
1614
|
+
let now = Date()
|
|
1615
|
+
switch packet.type {
|
|
1616
|
+
case NimoAudioParser.TYPE_OPUS_RIGHT:
|
|
1617
|
+
lastRightPacketTime = now
|
|
1618
|
+
preferRightMic = true
|
|
1619
|
+
case NimoAudioParser.TYPE_OPUS_LEFT:
|
|
1620
|
+
// Only fall back to the left ear when the right has gone quiet —
|
|
1621
|
+
// forwarding both would duplicate the audio.
|
|
1622
|
+
if preferRightMic,
|
|
1623
|
+
now.timeIntervalSince(lastRightPacketTime) < Const.micSideFallbackSeconds
|
|
1624
|
+
{
|
|
1625
|
+
return
|
|
1626
|
+
}
|
|
1627
|
+
preferRightMic = false
|
|
1628
|
+
default:
|
|
1629
|
+
return
|
|
1630
|
+
}
|
|
1631
|
+
|
|
1632
|
+
guard let opusDecoder else { return }
|
|
1633
|
+
for opusFrame in packet.opusFrames {
|
|
1634
|
+
if let pcm = opusDecoder.decode(opusFrame), !pcm.isEmpty {
|
|
1635
|
+
DeviceManager.shared.handlePcm(pcm)
|
|
1636
|
+
}
|
|
1637
|
+
}
|
|
1638
|
+
}
|
|
1639
|
+
|
|
1640
|
+
// MARK: - Bitmap Helpers
|
|
1641
|
+
|
|
1642
|
+
/// Aspect-fit the image into `width`x`height` on black, then convert to L8 grayscale.
|
|
1643
|
+
private func bitmapToGrayscale(_ image: UIImage, width: Int, height: Int) -> Data? {
|
|
1644
|
+
guard let cgImage = image.cgImage else { return nil }
|
|
1645
|
+
let colorSpace = CGColorSpaceCreateDeviceGray()
|
|
1646
|
+
guard let context = CGContext(
|
|
1647
|
+
data: nil,
|
|
1648
|
+
width: width,
|
|
1649
|
+
height: height,
|
|
1650
|
+
bitsPerComponent: 8,
|
|
1651
|
+
bytesPerRow: width,
|
|
1652
|
+
space: colorSpace,
|
|
1653
|
+
bitmapInfo: CGImageAlphaInfo.none.rawValue
|
|
1654
|
+
) else { return nil }
|
|
1655
|
+
|
|
1656
|
+
context.setFillColor(gray: 0, alpha: 1)
|
|
1657
|
+
context.fill(CGRect(x: 0, y: 0, width: width, height: height))
|
|
1658
|
+
let scale = min(CGFloat(width) / CGFloat(cgImage.width), CGFloat(height) / CGFloat(cgImage.height))
|
|
1659
|
+
let dw = CGFloat(cgImage.width) * scale
|
|
1660
|
+
let dh = CGFloat(cgImage.height) * scale
|
|
1661
|
+
let rect = CGRect(
|
|
1662
|
+
x: (CGFloat(width) - dw) / 2, y: (CGFloat(height) - dh) / 2, width: dw, height: dh
|
|
1663
|
+
)
|
|
1664
|
+
context.interpolationQuality = .medium
|
|
1665
|
+
context.draw(cgImage, in: rect)
|
|
1666
|
+
|
|
1667
|
+
guard let buffer = context.data else { return nil }
|
|
1668
|
+
// CGContext rows are top-down here; copy row by row in case bytesPerRow != width.
|
|
1669
|
+
// Invert luminance (255 - value): MentraOS app bitmaps follow the platform convention
|
|
1670
|
+
// of dark content on a light background, but Nimo's additive display lights up high
|
|
1671
|
+
// 2bpp values (3 = white). Without inverting, the background lights the whole lens and
|
|
1672
|
+
// the content stays dark — the inverted look. Flipping renders content lit on a dark lens.
|
|
1673
|
+
var gray = Data(capacity: width * height)
|
|
1674
|
+
let bytesPerRow = context.bytesPerRow
|
|
1675
|
+
let base = buffer.assumingMemoryBound(to: UInt8.self)
|
|
1676
|
+
for row in 0 ..< height {
|
|
1677
|
+
let rowOffset = row * bytesPerRow
|
|
1678
|
+
for col in 0 ..< width {
|
|
1679
|
+
gray.append(255 - base[rowOffset + col])
|
|
1680
|
+
}
|
|
1681
|
+
}
|
|
1682
|
+
return gray
|
|
1683
|
+
}
|
|
1684
|
+
|
|
1685
|
+
/// L8 → 2bpp: thresholds 0x40/0x80/0xC0, 4 pixels per byte, MSB first.
|
|
1686
|
+
private func packL8To2bpp(_ l8: Data) -> Data {
|
|
1687
|
+
let bytes = [UInt8](l8)
|
|
1688
|
+
let outBytes = (bytes.count + 3) >> 2
|
|
1689
|
+
var dst = Data(count: outBytes)
|
|
1690
|
+
for i in 0 ..< outBytes {
|
|
1691
|
+
var packed: UInt8 = 0
|
|
1692
|
+
for j in 0 ..< 4 {
|
|
1693
|
+
let idx = i * 4 + j
|
|
1694
|
+
var value: UInt8 = 0
|
|
1695
|
+
if idx < bytes.count {
|
|
1696
|
+
let px = bytes[idx]
|
|
1697
|
+
if px < 0x40 {
|
|
1698
|
+
value = 0
|
|
1699
|
+
} else if px < 0x80 {
|
|
1700
|
+
value = 1
|
|
1701
|
+
} else if px < 0xC0 {
|
|
1702
|
+
value = 2
|
|
1703
|
+
} else {
|
|
1704
|
+
value = 3
|
|
1705
|
+
}
|
|
1706
|
+
}
|
|
1707
|
+
packed |= (value & 0x03) << UInt8(6 - 2 * j)
|
|
1708
|
+
}
|
|
1709
|
+
dst[i] = packed
|
|
1710
|
+
}
|
|
1711
|
+
return dst
|
|
1712
|
+
}
|
|
1713
|
+
|
|
1714
|
+
/// zlib-compress (RFC1950); falls back to uncompressed when not smaller.
|
|
1715
|
+
/// Apple's Compression framework emits raw deflate, so the 2-byte zlib header
|
|
1716
|
+
/// and adler32 trailer the firmware expects are added manually.
|
|
1717
|
+
private func compressAdaptive(_ data: Data) -> (Data, Int) {
|
|
1718
|
+
guard !data.isEmpty, let compressed = zlibCompress(data), compressed.count < data.count
|
|
1719
|
+
else { return (data, NimoProtocol.COMPRESSION_NONE) }
|
|
1720
|
+
return (compressed, NimoProtocol.COMPRESSION_ZLIB)
|
|
1721
|
+
}
|
|
1722
|
+
|
|
1723
|
+
private func zlibCompress(_ data: Data) -> Data? {
|
|
1724
|
+
let dstCapacity = data.count + data.count / 16 + 256
|
|
1725
|
+
var deflated = Data(count: dstCapacity)
|
|
1726
|
+
let written = deflated.withUnsafeMutableBytes { (dst: UnsafeMutableRawBufferPointer) -> Int in
|
|
1727
|
+
data.withUnsafeBytes { (src: UnsafeRawBufferPointer) -> Int in
|
|
1728
|
+
compression_encode_buffer(
|
|
1729
|
+
dst.bindMemory(to: UInt8.self).baseAddress!, dstCapacity,
|
|
1730
|
+
src.bindMemory(to: UInt8.self).baseAddress!, data.count,
|
|
1731
|
+
nil, COMPRESSION_ZLIB
|
|
1732
|
+
)
|
|
1733
|
+
}
|
|
1734
|
+
}
|
|
1735
|
+
guard written > 0 else { return nil }
|
|
1736
|
+
deflated.removeSubrange(written ..< deflated.count)
|
|
1737
|
+
|
|
1738
|
+
var out = Data([0x78, 0x9C]) // zlib header, default compression
|
|
1739
|
+
out.append(deflated)
|
|
1740
|
+
// adler32 over the uncompressed data, big-endian
|
|
1741
|
+
var a: UInt32 = 1
|
|
1742
|
+
var b: UInt32 = 0
|
|
1743
|
+
for byte in data {
|
|
1744
|
+
a = (a + UInt32(byte)) % 65521
|
|
1745
|
+
b = (b + a) % 65521
|
|
1746
|
+
}
|
|
1747
|
+
let adler = (b << 16) | a
|
|
1748
|
+
out.append(contentsOf: [
|
|
1749
|
+
UInt8((adler >> 24) & 0xFF), UInt8((adler >> 16) & 0xFF),
|
|
1750
|
+
UInt8((adler >> 8) & 0xFF), UInt8(adler & 0xFF),
|
|
1751
|
+
])
|
|
1752
|
+
return out
|
|
1753
|
+
}
|
|
1754
|
+
}
|
|
1755
|
+
|
|
1756
|
+
// MARK: - CBCentralManagerDelegate
|
|
1757
|
+
|
|
1758
|
+
extension Nimo: CBCentralManagerDelegate {
|
|
1759
|
+
nonisolated func centralManagerDidUpdateState(_ central: CBCentralManager) {
|
|
1760
|
+
let state = central.state
|
|
1761
|
+
DispatchQueue.main.async { [weak self] in
|
|
1762
|
+
guard let self else { return }
|
|
1763
|
+
Bridge.log("NIMO: Bluetooth state: \(state.rawValue)")
|
|
1764
|
+
if state == .poweredOn {
|
|
1765
|
+
self.startScan()
|
|
1766
|
+
}
|
|
1767
|
+
}
|
|
1768
|
+
}
|
|
1769
|
+
|
|
1770
|
+
nonisolated func centralManager(
|
|
1771
|
+
_ central: CBCentralManager,
|
|
1772
|
+
didDiscover peripheral: CBPeripheral,
|
|
1773
|
+
advertisementData: [String: Any],
|
|
1774
|
+
rssi: NSNumber
|
|
1775
|
+
) {
|
|
1776
|
+
guard
|
|
1777
|
+
let name = peripheral.name
|
|
1778
|
+
?? advertisementData[CBAdvertisementDataLocalNameKey] as? String
|
|
1779
|
+
else { return }
|
|
1780
|
+
let rssiValue = rssi.intValue
|
|
1781
|
+
|
|
1782
|
+
DispatchQueue.main.async { [weak self] in
|
|
1783
|
+
guard let self else { return }
|
|
1784
|
+
guard self.isNimoMainDevice(name) else { return }
|
|
1785
|
+
|
|
1786
|
+
Bridge.sendDiscoveredDevice(DeviceTypes.NIMO, name, rssi: rssiValue)
|
|
1787
|
+
|
|
1788
|
+
guard self.DEVICE_SEARCH_ID != "NOT_SET" else { return }
|
|
1789
|
+
guard name == self.DEVICE_SEARCH_ID else { return }
|
|
1790
|
+
guard self.peripheral == nil else { return }
|
|
1791
|
+
|
|
1792
|
+
Bridge.log("NIMO: Connecting to \(name)")
|
|
1793
|
+
self.stopScan()
|
|
1794
|
+
self.lastDeviceName = name
|
|
1795
|
+
self.lastDeviceUUID = peripheral.identifier.uuidString
|
|
1796
|
+
self.peripheral = peripheral
|
|
1797
|
+
peripheral.delegate = self
|
|
1798
|
+
central.connect(peripheral, options: nil)
|
|
1799
|
+
}
|
|
1800
|
+
}
|
|
1801
|
+
|
|
1802
|
+
nonisolated func centralManager(_: CBCentralManager, didConnect peripheral: CBPeripheral) {
|
|
1803
|
+
DispatchQueue.main.async { [weak self] in
|
|
1804
|
+
guard let self else { return }
|
|
1805
|
+
Bridge.log("NIMO: Connected to \(peripheral.name ?? "unknown")")
|
|
1806
|
+
self.lastDeviceUUID = peripheral.identifier.uuidString
|
|
1807
|
+
peripheral.discoverServices([NimoBLE.SERVICE_UUID])
|
|
1808
|
+
}
|
|
1809
|
+
}
|
|
1810
|
+
|
|
1811
|
+
nonisolated func centralManager(
|
|
1812
|
+
_: CBCentralManager, didFailToConnect peripheral: CBPeripheral, error: Error?
|
|
1813
|
+
) {
|
|
1814
|
+
DispatchQueue.main.async { [weak self] in
|
|
1815
|
+
guard let self else { return }
|
|
1816
|
+
Bridge.log(
|
|
1817
|
+
"NIMO: Failed to connect \(peripheral.name ?? "unknown"): \(error?.localizedDescription ?? "unknown")"
|
|
1818
|
+
)
|
|
1819
|
+
self.peripheral = nil
|
|
1820
|
+
self.startReconnectionTimer()
|
|
1821
|
+
}
|
|
1822
|
+
}
|
|
1823
|
+
|
|
1824
|
+
nonisolated func centralManager(
|
|
1825
|
+
_: CBCentralManager, didDisconnectPeripheral _: CBPeripheral, error: Error?
|
|
1826
|
+
) {
|
|
1827
|
+
DispatchQueue.main.async { [weak self] in
|
|
1828
|
+
guard let self else { return }
|
|
1829
|
+
Bridge.log("NIMO: Disconnected: \(error?.localizedDescription ?? "clean")")
|
|
1830
|
+
if self.isDisconnecting { return }
|
|
1831
|
+
|
|
1832
|
+
self.peripheral = nil
|
|
1833
|
+
self.txChar = nil
|
|
1834
|
+
self.rxChar = nil
|
|
1835
|
+
self.micChar = nil
|
|
1836
|
+
self.resetSessionState()
|
|
1837
|
+
|
|
1838
|
+
DeviceStore.shared.apply("glasses", "connected", false)
|
|
1839
|
+
DeviceStore.shared.apply("glasses", "fullyBooted", false)
|
|
1840
|
+
DeviceStore.shared.apply("glasses", "connectionState", ConnTypes.DISCONNECTED)
|
|
1841
|
+
self.startReconnectionTimer()
|
|
1842
|
+
}
|
|
1843
|
+
}
|
|
1844
|
+
}
|
|
1845
|
+
|
|
1846
|
+
// MARK: - CBPeripheralDelegate
|
|
1847
|
+
|
|
1848
|
+
extension Nimo: CBPeripheralDelegate {
|
|
1849
|
+
nonisolated func peripheral(_ peripheral: CBPeripheral, didDiscoverServices _: Error?) {
|
|
1850
|
+
guard let services = peripheral.services else { return }
|
|
1851
|
+
for service in services where service.uuid == NimoBLE.SERVICE_UUID {
|
|
1852
|
+
peripheral.discoverCharacteristics(nil, for: service)
|
|
1853
|
+
}
|
|
1854
|
+
}
|
|
1855
|
+
|
|
1856
|
+
nonisolated func peripheral(
|
|
1857
|
+
_ peripheral: CBPeripheral, didDiscoverCharacteristicsFor service: CBService, error _: Error?
|
|
1858
|
+
) {
|
|
1859
|
+
guard service.uuid == NimoBLE.SERVICE_UUID,
|
|
1860
|
+
let characteristics = service.characteristics
|
|
1861
|
+
else { return }
|
|
1862
|
+
|
|
1863
|
+
DispatchQueue.main.async { [weak self] in
|
|
1864
|
+
guard let self else { return }
|
|
1865
|
+
for char in characteristics {
|
|
1866
|
+
switch char.uuid {
|
|
1867
|
+
case NimoBLE.CHAR_TX:
|
|
1868
|
+
self.txChar = char
|
|
1869
|
+
case NimoBLE.CHAR_RX:
|
|
1870
|
+
self.rxChar = char
|
|
1871
|
+
peripheral.setNotifyValue(true, for: char)
|
|
1872
|
+
case NimoBLE.CHAR_MIC:
|
|
1873
|
+
self.micChar = char
|
|
1874
|
+
peripheral.setNotifyValue(true, for: char)
|
|
1875
|
+
default:
|
|
1876
|
+
break
|
|
1877
|
+
}
|
|
1878
|
+
}
|
|
1879
|
+
Bridge.log(
|
|
1880
|
+
"NIMO: chars tx=\(self.txChar != nil) rx=\(self.rxChar != nil) mic=\(self.micChar != nil)"
|
|
1881
|
+
)
|
|
1882
|
+
}
|
|
1883
|
+
}
|
|
1884
|
+
|
|
1885
|
+
nonisolated func peripheral(
|
|
1886
|
+
_: CBPeripheral, didUpdateNotificationStateFor characteristic: CBCharacteristic,
|
|
1887
|
+
error: Error?
|
|
1888
|
+
) {
|
|
1889
|
+
DispatchQueue.main.async { [weak self] in
|
|
1890
|
+
guard let self else { return }
|
|
1891
|
+
if let error {
|
|
1892
|
+
Bridge.log("NIMO: notify enable failed: \(error.localizedDescription)")
|
|
1893
|
+
return
|
|
1894
|
+
}
|
|
1895
|
+
// RX notifications live → the data channel is usable; run the handshake.
|
|
1896
|
+
if characteristic.uuid == NimoBLE.CHAR_RX, self.handshakeState == .idle {
|
|
1897
|
+
self.startHandshake()
|
|
1898
|
+
}
|
|
1899
|
+
}
|
|
1900
|
+
}
|
|
1901
|
+
|
|
1902
|
+
nonisolated func peripheral(
|
|
1903
|
+
_: CBPeripheral, didUpdateValueFor characteristic: CBCharacteristic, error: Error?
|
|
1904
|
+
) {
|
|
1905
|
+
guard let data = characteristic.value, error == nil else { return }
|
|
1906
|
+
let uuid = characteristic.uuid
|
|
1907
|
+
DispatchQueue.main.async { [weak self] in
|
|
1908
|
+
guard let self else { return }
|
|
1909
|
+
if uuid == NimoBLE.CHAR_RX {
|
|
1910
|
+
self.handleRxPacket(data)
|
|
1911
|
+
} else if uuid == NimoBLE.CHAR_MIC {
|
|
1912
|
+
self.handleMicPacket(data)
|
|
1913
|
+
}
|
|
1914
|
+
}
|
|
1915
|
+
}
|
|
1916
|
+
|
|
1917
|
+
nonisolated func peripheral(
|
|
1918
|
+
_: CBPeripheral, didWriteValueFor _: CBCharacteristic, error: Error?
|
|
1919
|
+
) {
|
|
1920
|
+
DispatchQueue.main.async { [weak self] in
|
|
1921
|
+
guard let self else { return }
|
|
1922
|
+
if let error {
|
|
1923
|
+
Bridge.log("NIMO: Write error: \(error.localizedDescription)")
|
|
1924
|
+
}
|
|
1925
|
+
self.onWriteCompleted()
|
|
1926
|
+
}
|
|
1927
|
+
}
|
|
1928
|
+
}
|