@mentra/bluetooth-sdk 0.1.14 → 0.1.17
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/BluetoothSdkDefaults.swift +2 -30
- 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 +9 -1
- package/scripts/inject-ios-sdk-version.mjs +65 -0
- 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,2532 @@
|
|
|
1
|
+
package com.mentra.bluetoothsdk.sgcs
|
|
2
|
+
|
|
3
|
+
import android.bluetooth.BluetoothAdapter
|
|
4
|
+
import android.bluetooth.BluetoothGatt
|
|
5
|
+
import android.bluetooth.BluetoothGattCallback
|
|
6
|
+
import android.bluetooth.BluetoothGattCharacteristic
|
|
7
|
+
import android.bluetooth.BluetoothGattDescriptor
|
|
8
|
+
import android.bluetooth.BluetoothProfile
|
|
9
|
+
import android.content.Context
|
|
10
|
+
import android.graphics.Bitmap
|
|
11
|
+
import android.graphics.BitmapFactory
|
|
12
|
+
import android.graphics.Canvas
|
|
13
|
+
import android.graphics.Color
|
|
14
|
+
import android.graphics.Paint
|
|
15
|
+
import android.graphics.Rect
|
|
16
|
+
import android.media.MediaCodec
|
|
17
|
+
import android.media.MediaFormat
|
|
18
|
+
import android.os.Handler
|
|
19
|
+
import android.os.HandlerThread
|
|
20
|
+
import android.os.Looper
|
|
21
|
+
import android.util.Base64
|
|
22
|
+
import com.mentra.bluetoothsdk.Bridge
|
|
23
|
+
import com.mentra.bluetoothsdk.DeviceManager
|
|
24
|
+
import com.mentra.bluetoothsdk.DeviceStore
|
|
25
|
+
import com.mentra.bluetoothsdk.PhotoRequest
|
|
26
|
+
import com.mentra.bluetoothsdk.utils.ConnTypes
|
|
27
|
+
import com.mentra.bluetoothsdk.utils.DeviceTypes
|
|
28
|
+
import java.io.ByteArrayOutputStream
|
|
29
|
+
import java.nio.ByteBuffer
|
|
30
|
+
import java.nio.ByteOrder
|
|
31
|
+
import java.util.Calendar
|
|
32
|
+
import java.util.TimeZone
|
|
33
|
+
import java.util.UUID
|
|
34
|
+
import java.util.zip.Deflater
|
|
35
|
+
|
|
36
|
+
// ---------- Nimo BLE Constants ----------
|
|
37
|
+
|
|
38
|
+
internal object NimoBLE {
|
|
39
|
+
// UART-style service: TX = phone→glasses commands, RX = glasses→phone notifications.
|
|
40
|
+
// Short UUIDs from the vendor SDK expanded onto the Bluetooth base UUID.
|
|
41
|
+
val SERVICE_UUID: UUID = UUID.fromString("00007033-0000-1000-8000-00805F9B34FB")
|
|
42
|
+
val CHAR_TX: UUID = UUID.fromString("00002021-0000-1000-8000-00805F9B34FB")
|
|
43
|
+
val CHAR_RX: UUID = UUID.fromString("00002022-0000-1000-8000-00805F9B34FB")
|
|
44
|
+
val CHAR_MIC: UUID = UUID.fromString("00002025-0000-1000-8000-00805F9B34FB")
|
|
45
|
+
val CLIENT_CHARACTERISTIC_CONFIG: UUID = UUID.fromString("00002902-0000-1000-8000-00805f9b34fb")
|
|
46
|
+
|
|
47
|
+
const val NAME_PREFIX = "nimo"
|
|
48
|
+
// iOS ANCS side-channel devices advertise "<name>_ble" — never the data channel.
|
|
49
|
+
const val BLE_NAME_SUFFIX = "_ble"
|
|
50
|
+
|
|
51
|
+
const val CHUNK_SIZE = 501
|
|
52
|
+
const val INTER_FRAME_DELAY_MS = 5L
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// ---------- Nimo Protocol Constants ----------
|
|
56
|
+
// Byte values follow the glasses firmware protocol and must not be changed.
|
|
57
|
+
|
|
58
|
+
internal object NimoProtocol {
|
|
59
|
+
const val FRAME_MAGIC = 0xBF
|
|
60
|
+
|
|
61
|
+
// status bits
|
|
62
|
+
const val STATUS_ERR = 0x01
|
|
63
|
+
const val STATUS_ACK = 0x02
|
|
64
|
+
|
|
65
|
+
// command categories
|
|
66
|
+
const val CMD_GET_PARAMETER = 0x02
|
|
67
|
+
const val CMD_SET_PARAMETER = 0x03
|
|
68
|
+
const val CMD_INSTRUCTION_REPORT = 0x06
|
|
69
|
+
const val CMD_CONTROL_INSTRUCTION = 0x07
|
|
70
|
+
const val CMD_CONTROL_FACTORY = 0x08
|
|
71
|
+
const val CMD_CONTROL_NOTIFICATION = 0x09
|
|
72
|
+
|
|
73
|
+
// get parameter keys
|
|
74
|
+
const val GET_BRIGHTNESS = 0x02
|
|
75
|
+
const val GET_BATTERY = 0x06
|
|
76
|
+
const val GET_SCREEN_INFO = 0x07
|
|
77
|
+
const val GET_VERSION = 0x0A
|
|
78
|
+
const val GET_VERSION_DETAIL = 0x0B
|
|
79
|
+
const val GET_TWS_STATUS = 0x16
|
|
80
|
+
|
|
81
|
+
// set parameter keys
|
|
82
|
+
const val SET_TIME = 0x01
|
|
83
|
+
const val SET_BRIGHTNESS = 0x02
|
|
84
|
+
const val SET_DISTANCE = 0x03
|
|
85
|
+
const val SET_ANGLE = 0x04
|
|
86
|
+
const val SET_HEADUP_DISPLAY = 0x0D
|
|
87
|
+
const val SET_AUTO_BRIGHTNESS = 0x0E
|
|
88
|
+
const val SET_DISPLAY_OFF = 0x0F
|
|
89
|
+
const val SET_PHONE_TYPE = 0x14
|
|
90
|
+
const val SET_HEIGHT_LEVEL = 0x17
|
|
91
|
+
|
|
92
|
+
// control instruction keys
|
|
93
|
+
const val CTRL_ENTER_APP = 0x01
|
|
94
|
+
const val CTRL_QUIT_APP = 0x03
|
|
95
|
+
const val CTRL_UPDATE_CONTENT = 0x04
|
|
96
|
+
|
|
97
|
+
// notification keys
|
|
98
|
+
const val NOTIFICATION_SEND = 0x01
|
|
99
|
+
|
|
100
|
+
// report keys (cmd 0x06)
|
|
101
|
+
const val REPORT_INPUT = 0x01
|
|
102
|
+
const val REPORT_APP = 0x02
|
|
103
|
+
const val REPORT_TWS = 0x03
|
|
104
|
+
const val REPORT_BUSINESS = 0x04
|
|
105
|
+
const val REPORT_GATT_STATE = 0x05
|
|
106
|
+
|
|
107
|
+
// business report ids
|
|
108
|
+
const val BUSINESS_MESSAGE_PUSH = 0x01
|
|
109
|
+
const val BUSINESS_HEARTBEAT = 0x03
|
|
110
|
+
const val BUSINESS_BATTERY = 0x05
|
|
111
|
+
|
|
112
|
+
// input event codes
|
|
113
|
+
const val INPUT_HEAD_UP = 0x01
|
|
114
|
+
const val INPUT_HEAD_DOWN = 0x02
|
|
115
|
+
const val INPUT_CLICK_RIGHT = 0x03
|
|
116
|
+
const val INPUT_DOUBLE_CLICK_RIGHT = 0x04
|
|
117
|
+
const val INPUT_LONG_PRESS_RIGHT = 0x05
|
|
118
|
+
const val INPUT_TOUCH_PRESS_RIGHT = 0x06
|
|
119
|
+
const val INPUT_TOUCH_RELEASE_RIGHT = 0x07
|
|
120
|
+
const val INPUT_CLICK_LEFT = 0x13
|
|
121
|
+
const val INPUT_DOUBLE_CLICK_LEFT = 0x14
|
|
122
|
+
const val INPUT_LONG_PRESS_LEFT = 0x15
|
|
123
|
+
const val INPUT_TOUCH_PRESS_LEFT = 0x16
|
|
124
|
+
const val INPUT_TOUCH_RELEASE_LEFT = 0x17
|
|
125
|
+
|
|
126
|
+
// app state report phases
|
|
127
|
+
const val STATE_ENTER = 0x01
|
|
128
|
+
const val STATE_EXIT = 0x03
|
|
129
|
+
|
|
130
|
+
// app ids
|
|
131
|
+
const val APP_ID_DASHBOARD = 0x00
|
|
132
|
+
const val APP_ID_NAV = 0x01
|
|
133
|
+
const val APP_ID_ASR_NOTE = 0x04
|
|
134
|
+
const val APP_ID_PROMPTER = 0x06
|
|
135
|
+
const val APP_ID_AI_TALK = 0x07
|
|
136
|
+
|
|
137
|
+
// enterApp modes
|
|
138
|
+
const val APP_MODE_STANDALONE = 0x00
|
|
139
|
+
// Undocumented: defined in the vendor SDK constants (appModePopup) but never used there.
|
|
140
|
+
// Hardware-tested June 2026: does NOT remove the ASR view's time/battery status bar.
|
|
141
|
+
const val APP_MODE_POPUP = 0x01
|
|
142
|
+
|
|
143
|
+
// widget resTypes
|
|
144
|
+
const val WIDGET_TEXT_NEW = 0x00
|
|
145
|
+
const val WIDGET_TEXT_APPEND = 0x01
|
|
146
|
+
const val WIDGET_PICTURE = 0x80
|
|
147
|
+
|
|
148
|
+
// navigation widget resIds (appId 0x01): mini map 0x00, arrow 0x01, turn text 0x02,
|
|
149
|
+
// status bar 0x03 (raw), tip 0x04, large map 0x05.
|
|
150
|
+
const val NAV_RES_MINI_MAP = 0x00
|
|
151
|
+
const val NAV_RES_TURN_TEXT = 0x02
|
|
152
|
+
const val NAV_RES_LARGE_MAP = 0x05
|
|
153
|
+
|
|
154
|
+
// navigation image widget sizes (hard firmware requirements; see IMAGE_PROTOCOL).
|
|
155
|
+
const val NAV_MINI_MAP_SIZE = 160
|
|
156
|
+
const val NAV_LARGE_MAP_WIDTH = 452
|
|
157
|
+
const val NAV_LARGE_MAP_HEIGHT = 170
|
|
158
|
+
|
|
159
|
+
// phone types
|
|
160
|
+
const val PHONE_TYPE_OTHER = 0x02
|
|
161
|
+
|
|
162
|
+
// image header
|
|
163
|
+
const val COM_IMAGE_HEADER = 0x16
|
|
164
|
+
const val FORMAT_2BPP = 0x02
|
|
165
|
+
const val COMPRESSION_NONE = 0x00
|
|
166
|
+
const val COMPRESSION_ZLIB = 0x07
|
|
167
|
+
|
|
168
|
+
const val MAX_BRIGHTNESS_LEVEL = 16
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// ---------- CRC16-CCITT ----------
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* CRC-16/CCITT-FALSE: init 0xFFFF, poly 0x1021, no reflection, no final XOR.
|
|
175
|
+
* Computed over the application payload (app header + data), NOT the transport header.
|
|
176
|
+
*/
|
|
177
|
+
internal fun nimoCrc16(data: ByteArray): Int {
|
|
178
|
+
var crc = 0xFFFF
|
|
179
|
+
for (byte in data) {
|
|
180
|
+
crc = crc xor ((byte.toInt() and 0xFF) shl 8)
|
|
181
|
+
for (i in 0 until 8) {
|
|
182
|
+
crc =
|
|
183
|
+
if (crc and 0x8000 != 0) {
|
|
184
|
+
((crc shl 1) xor 0x1021) and 0xFFFF
|
|
185
|
+
} else {
|
|
186
|
+
(crc shl 1) and 0xFFFF
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
return crc
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// ---------- Frame Codec ----------
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Pure bytes-in/bytes-out frame codec for the Nimo 0xBF transport.
|
|
197
|
+
*
|
|
198
|
+
* Frame layout (little-endian):
|
|
199
|
+
* - 8-byte transport header: [magic][status][len(2)][crc16(2)][index(2)]
|
|
200
|
+
* - 4-byte application header (requests): [cmd][key][len(2)]
|
|
201
|
+
* - responses/reports carry an extra status byte: [cmd][key][len(2)][status][data...]
|
|
202
|
+
*/
|
|
203
|
+
internal object NimoFrameCodec {
|
|
204
|
+
|
|
205
|
+
fun transportHeader(payload: ByteArray, index: Int = 0, needsAck: Boolean = true): ByteArray {
|
|
206
|
+
val header = ByteArray(8)
|
|
207
|
+
header[0] = NimoProtocol.FRAME_MAGIC.toByte()
|
|
208
|
+
header[1] = (if (needsAck) NimoProtocol.STATUS_ACK else 0).toByte()
|
|
209
|
+
header[2] = (payload.size and 0xFF).toByte()
|
|
210
|
+
header[3] = ((payload.size shr 8) and 0xFF).toByte()
|
|
211
|
+
val crc = nimoCrc16(payload)
|
|
212
|
+
header[4] = (crc and 0xFF).toByte()
|
|
213
|
+
header[5] = ((crc shr 8) and 0xFF).toByte()
|
|
214
|
+
header[6] = (index and 0xFF).toByte()
|
|
215
|
+
header[7] = ((index shr 8) and 0xFF).toByte()
|
|
216
|
+
return header
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
fun applicationHeader(cmd: Int, key: Int, payloadSize: Int): ByteArray {
|
|
220
|
+
return byteArrayOf(
|
|
221
|
+
cmd.toByte(),
|
|
222
|
+
key.toByte(),
|
|
223
|
+
(payloadSize and 0xFF).toByte(),
|
|
224
|
+
((payloadSize shr 8) and 0xFF).toByte()
|
|
225
|
+
)
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/** One complete single frame: transport header + app header + payload. */
|
|
229
|
+
fun encodeFrame(
|
|
230
|
+
cmd: Int,
|
|
231
|
+
key: Int,
|
|
232
|
+
payload: ByteArray = ByteArray(0),
|
|
233
|
+
index: Int = 0,
|
|
234
|
+
needsAck: Boolean = true
|
|
235
|
+
): ByteArray {
|
|
236
|
+
val appHeader = applicationHeader(cmd, key, payload.size)
|
|
237
|
+
val transportPayload = appHeader + payload
|
|
238
|
+
return transportHeader(transportPayload, index, needsAck) + transportPayload
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* Content update (cmd=0x07 key=0x04) blind-sliced into 501-byte chunks, each wrapped in its
|
|
243
|
+
* own transport header. Index rule: last chunk = 0, others = i+1 (first = 1); single chunk = 0.
|
|
244
|
+
*/
|
|
245
|
+
fun updateContentFrames(
|
|
246
|
+
appId: Int,
|
|
247
|
+
layoutId: Int,
|
|
248
|
+
resId: Int,
|
|
249
|
+
resType: Int,
|
|
250
|
+
content: ByteArray,
|
|
251
|
+
chunkSize: Int = NimoBLE.CHUNK_SIZE
|
|
252
|
+
): List<ByteArray> {
|
|
253
|
+
// App header payloadSize includes the 4-byte [appId][layoutId][resId][resType] prefix.
|
|
254
|
+
val appHeader =
|
|
255
|
+
applicationHeader(
|
|
256
|
+
NimoProtocol.CMD_CONTROL_INSTRUCTION,
|
|
257
|
+
NimoProtocol.CTRL_UPDATE_CONTENT,
|
|
258
|
+
4 + content.size
|
|
259
|
+
)
|
|
260
|
+
val full =
|
|
261
|
+
appHeader +
|
|
262
|
+
byteArrayOf(
|
|
263
|
+
appId.toByte(),
|
|
264
|
+
layoutId.toByte(),
|
|
265
|
+
resId.toByte(),
|
|
266
|
+
resType.toByte()
|
|
267
|
+
) +
|
|
268
|
+
content
|
|
269
|
+
|
|
270
|
+
val chunkCount = (full.size + chunkSize - 1) / chunkSize
|
|
271
|
+
val frames = mutableListOf<ByteArray>()
|
|
272
|
+
for (i in 0 until chunkCount) {
|
|
273
|
+
val start = i * chunkSize
|
|
274
|
+
val end = minOf(start + chunkSize, full.size)
|
|
275
|
+
val chunk = full.copyOfRange(start, end)
|
|
276
|
+
val isLast = i == chunkCount - 1
|
|
277
|
+
val index = if (isLast) 0 else i + 1
|
|
278
|
+
frames.add(transportHeader(chunk, index) + chunk)
|
|
279
|
+
}
|
|
280
|
+
return frames
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/**
|
|
284
|
+
* 15-byte image header. [originalSize] MUST be the uncompressed pixel byte count
|
|
285
|
+
* (the glasses use it to allocate the decompression buffer).
|
|
286
|
+
*/
|
|
287
|
+
fun imageHeader(
|
|
288
|
+
width: Int,
|
|
289
|
+
height: Int,
|
|
290
|
+
formatBpp: Int,
|
|
291
|
+
compression: Int,
|
|
292
|
+
originalSize: Int,
|
|
293
|
+
compressedSize: Int
|
|
294
|
+
): ByteArray {
|
|
295
|
+
val p = ByteArray(15)
|
|
296
|
+
p[0] = NimoProtocol.COM_IMAGE_HEADER.toByte()
|
|
297
|
+
p[1] = (width and 0xFF).toByte()
|
|
298
|
+
p[2] = ((width shr 8) and 0xFF).toByte()
|
|
299
|
+
p[3] = (height and 0xFF).toByte()
|
|
300
|
+
p[4] = ((height shr 8) and 0xFF).toByte()
|
|
301
|
+
p[5] = formatBpp.toByte()
|
|
302
|
+
p[6] = compression.toByte()
|
|
303
|
+
p[7] = (originalSize and 0xFF).toByte()
|
|
304
|
+
p[8] = ((originalSize shr 8) and 0xFF).toByte()
|
|
305
|
+
p[9] = ((originalSize shr 16) and 0xFF).toByte()
|
|
306
|
+
p[10] = ((originalSize shr 24) and 0xFF).toByte()
|
|
307
|
+
p[11] = (compressedSize and 0xFF).toByte()
|
|
308
|
+
p[12] = ((compressedSize shr 8) and 0xFF).toByte()
|
|
309
|
+
p[13] = ((compressedSize shr 16) and 0xFF).toByte()
|
|
310
|
+
p[14] = ((compressedSize shr 24) and 0xFF).toByte()
|
|
311
|
+
return p
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/**
|
|
315
|
+
* 9-byte device time: [year(2 LE)][month][day][hour][min][sec][week][zone]
|
|
316
|
+
* week: Sunday=0..Saturday=6; zone: signed, 15-minute units, clamped to ±48.
|
|
317
|
+
*/
|
|
318
|
+
fun encodeDeviceTime(timeMillis: Long = System.currentTimeMillis()): ByteArray {
|
|
319
|
+
val cal = Calendar.getInstance()
|
|
320
|
+
cal.timeInMillis = timeMillis
|
|
321
|
+
val b = ByteArray(9)
|
|
322
|
+
val year = cal.get(Calendar.YEAR)
|
|
323
|
+
b[0] = (year and 0xFF).toByte()
|
|
324
|
+
b[1] = ((year shr 8) and 0xFF).toByte()
|
|
325
|
+
b[2] = (cal.get(Calendar.MONTH) + 1).toByte()
|
|
326
|
+
b[3] = cal.get(Calendar.DAY_OF_MONTH).toByte()
|
|
327
|
+
b[4] = cal.get(Calendar.HOUR_OF_DAY).toByte()
|
|
328
|
+
b[5] = cal.get(Calendar.MINUTE).toByte()
|
|
329
|
+
b[6] = cal.get(Calendar.SECOND).toByte()
|
|
330
|
+
// Calendar.DAY_OF_WEEK: Sunday=1..Saturday=7 → protocol Sunday=0..Saturday=6
|
|
331
|
+
b[7] = (cal.get(Calendar.DAY_OF_WEEK) - 1).toByte()
|
|
332
|
+
val offsetMinutes = TimeZone.getDefault().getOffset(timeMillis) / 60_000
|
|
333
|
+
val zone = (offsetMinutes / 15.0).toInt().coerceIn(-48, 48)
|
|
334
|
+
b[8] = (zone and 0xFF).toByte()
|
|
335
|
+
return b
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/** A decoded frame. [cmd]/[key]/[statusCode]/[data] are null when the payload is too short. */
|
|
339
|
+
data class DecodedFrame(
|
|
340
|
+
val transportStatus: Int,
|
|
341
|
+
val index: Int,
|
|
342
|
+
val cmd: Int?,
|
|
343
|
+
val key: Int?,
|
|
344
|
+
val statusCode: Int?,
|
|
345
|
+
val data: ByteArray?
|
|
346
|
+
)
|
|
347
|
+
|
|
348
|
+
/**
|
|
349
|
+
* Decodes one complete frame (responses and reports both use the 5-byte
|
|
350
|
+
* [cmd][key][len(2)][status] application header). Returns null on transport
|
|
351
|
+
* error, bad CRC, or truncation.
|
|
352
|
+
*/
|
|
353
|
+
fun decode(frame: ByteArray): DecodedFrame? {
|
|
354
|
+
if (frame.size < 8) return null
|
|
355
|
+
if ((frame[0].toInt() and 0xFF) != NimoProtocol.FRAME_MAGIC) return null
|
|
356
|
+
val transportStatus = frame[1].toInt() and 0xFF
|
|
357
|
+
val payloadLen = (frame[2].toInt() and 0xFF) or ((frame[3].toInt() and 0xFF) shl 8)
|
|
358
|
+
val crcValue = (frame[4].toInt() and 0xFF) or ((frame[5].toInt() and 0xFF) shl 8)
|
|
359
|
+
val index = (frame[6].toInt() and 0xFF) or ((frame[7].toInt() and 0xFF) shl 8)
|
|
360
|
+
if (frame.size < 8 + payloadLen) return null
|
|
361
|
+
if (transportStatus and NimoProtocol.STATUS_ERR != 0) return null
|
|
362
|
+
|
|
363
|
+
val payload = frame.copyOfRange(8, 8 + payloadLen)
|
|
364
|
+
if (nimoCrc16(payload) != crcValue) return null
|
|
365
|
+
|
|
366
|
+
if (payload.size < 5) {
|
|
367
|
+
return DecodedFrame(transportStatus, index, null, null, null, null)
|
|
368
|
+
}
|
|
369
|
+
val cmd = payload[0].toInt() and 0xFF
|
|
370
|
+
val key = payload[1].toInt() and 0xFF
|
|
371
|
+
val statusCode = payload[4].toInt() and 0xFF
|
|
372
|
+
val data = if (payload.size > 5) payload.copyOfRange(5, payload.size) else ByteArray(0)
|
|
373
|
+
return DecodedFrame(transportStatus, index, cmd, key, statusCode, data)
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
// ---------- Receive Assembler ----------
|
|
378
|
+
|
|
379
|
+
/**
|
|
380
|
+
* Reassembles multi-packet responses. Fragments are grouped by (cmd,key); each fragment carries
|
|
381
|
+
* the full 5-byte response app header. The first fragment (index==1) keeps its header, the
|
|
382
|
+
* continuation fragments (index>1) and the last fragment (index==0) contribute only their data
|
|
383
|
+
* sections, concatenated in ascending index order with the last (0) treated as largest. The
|
|
384
|
+
* merged message is re-framed (lengths and CRC recomputed) so [NimoFrameCodec.decode] can parse
|
|
385
|
+
* it. Single packets (index==0, no cached group) pass through unchanged.
|
|
386
|
+
*/
|
|
387
|
+
internal class NimoReceiveAssembler {
|
|
388
|
+
private class Pending {
|
|
389
|
+
var firstAppPayload: ByteArray? = null
|
|
390
|
+
val dataByIndex = mutableMapOf<Int, ByteArray>()
|
|
391
|
+
val startTime = System.currentTimeMillis()
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
private val pending = mutableMapOf<Int, Pending>()
|
|
395
|
+
|
|
396
|
+
fun ingest(packet: ByteArray): List<ByteArray> {
|
|
397
|
+
if (packet.size < 8) return emptyList()
|
|
398
|
+
val payloadLen = (packet[2].toInt() and 0xFF) or ((packet[3].toInt() and 0xFF) shl 8)
|
|
399
|
+
val index = (packet[6].toInt() and 0xFF) or ((packet[7].toInt() and 0xFF) shl 8)
|
|
400
|
+
if (packet.size < 8 + payloadLen) return emptyList()
|
|
401
|
+
val appPayload = packet.copyOfRange(8, 8 + payloadLen)
|
|
402
|
+
|
|
403
|
+
if (appPayload.size < 2) {
|
|
404
|
+
return if (index == 0) listOf(packet) else emptyList()
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
val cmd = appPayload[0].toInt() and 0xFF
|
|
408
|
+
val key = appPayload[1].toInt() and 0xFF
|
|
409
|
+
val groupKey = (cmd shl 8) or key
|
|
410
|
+
|
|
411
|
+
if (index == 0) {
|
|
412
|
+
val p = pending.remove(groupKey) ?: return listOf(packet)
|
|
413
|
+
p.dataByIndex[Int.MAX_VALUE] = dataSection(appPayload)
|
|
414
|
+
return listOf(reframe(p))
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
if (index == 1) {
|
|
418
|
+
// A new first fragment while an old first fragment is cached means the previous
|
|
419
|
+
// round lost its last packet — drop the stale group and start over.
|
|
420
|
+
val existing = pending[groupKey]
|
|
421
|
+
if (existing?.firstAppPayload != null) {
|
|
422
|
+
pending.remove(groupKey)
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
val p = pending.getOrPut(groupKey) { Pending() }
|
|
427
|
+
if (index == 1) {
|
|
428
|
+
p.firstAppPayload = appPayload
|
|
429
|
+
}
|
|
430
|
+
p.dataByIndex[index] = dataSection(appPayload)
|
|
431
|
+
return emptyList()
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
fun cleanup(timeoutMs: Long = 10_000) {
|
|
435
|
+
val now = System.currentTimeMillis()
|
|
436
|
+
pending.entries.removeAll { now - it.value.startTime >= timeoutMs }
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
fun reset() = pending.clear()
|
|
440
|
+
|
|
441
|
+
private fun dataSection(appPayload: ByteArray): ByteArray {
|
|
442
|
+
return if (appPayload.size <= 5) ByteArray(0) else appPayload.copyOfRange(5, appPayload.size)
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
private fun reframe(p: Pending): ByteArray {
|
|
446
|
+
val merged = ByteArrayOutputStream()
|
|
447
|
+
for (k in p.dataByIndex.keys.sorted()) {
|
|
448
|
+
merged.write(p.dataByIndex[k]!!)
|
|
449
|
+
}
|
|
450
|
+
val mergedData = merged.toByteArray()
|
|
451
|
+
val first = p.firstAppPayload
|
|
452
|
+
val cmd = if (first != null && first.isNotEmpty()) first[0] else 0
|
|
453
|
+
val key = if (first != null && first.size >= 2) first[1] else 0
|
|
454
|
+
val status = if (first != null && first.size >= 5) first[4] else 0
|
|
455
|
+
|
|
456
|
+
val appPayload = ByteArray(5 + mergedData.size)
|
|
457
|
+
appPayload[0] = cmd
|
|
458
|
+
appPayload[1] = key
|
|
459
|
+
appPayload[2] = (mergedData.size and 0xFF).toByte()
|
|
460
|
+
appPayload[3] = ((mergedData.size shr 8) and 0xFF).toByte()
|
|
461
|
+
appPayload[4] = status
|
|
462
|
+
mergedData.copyInto(appPayload, 5)
|
|
463
|
+
|
|
464
|
+
return NimoFrameCodec.transportHeader(appPayload, index = 0) + appPayload
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
// ---------- Mic Audio Parser ----------
|
|
469
|
+
|
|
470
|
+
/**
|
|
471
|
+
* H-T-L-V parser for the mic channel (char 2025). Packets: [0x52][type][len(2 LE)][payload].
|
|
472
|
+
* For Opus packets the payload is [SN(2 LE)][frameCnt][reserved] then frameCnt frames of
|
|
473
|
+
* [frameLen(1)][body] where opusLen = body[3] and the Opus bytes are body[8 .. 8+opusLen].
|
|
474
|
+
* Slicing by opusLen (not frameLen-8) is mandatory — the tail holds CRC/padding bytes.
|
|
475
|
+
*/
|
|
476
|
+
internal object NimoAudioParser {
|
|
477
|
+
const val HEADER = 0x52
|
|
478
|
+
const val TYPE_STOP = 0x00
|
|
479
|
+
const val TYPE_START = 0x01
|
|
480
|
+
const val TYPE_OPUS_LEFT = 0x02
|
|
481
|
+
const val TYPE_OPUS_RIGHT = 0x03
|
|
482
|
+
|
|
483
|
+
data class Packet(val type: Int, val sequence: Int, val opusFrames: List<ByteArray>)
|
|
484
|
+
|
|
485
|
+
fun parse(data: ByteArray): Packet? {
|
|
486
|
+
if (data.size < 4 || (data[0].toInt() and 0xFF) != HEADER) return null
|
|
487
|
+
val type = data[1].toInt() and 0xFF
|
|
488
|
+
val len = (data[2].toInt() and 0xFF) or ((data[3].toInt() and 0xFF) shl 8)
|
|
489
|
+
if (data.size < 4 + len) return Packet(type, 0, emptyList())
|
|
490
|
+
if (type != TYPE_OPUS_LEFT && type != TYPE_OPUS_RIGHT) return Packet(type, 0, emptyList())
|
|
491
|
+
|
|
492
|
+
val payload = data.copyOfRange(4, 4 + len)
|
|
493
|
+
if (payload.size < 4) return Packet(type, 0, emptyList())
|
|
494
|
+
val sn = (payload[0].toInt() and 0xFF) or ((payload[1].toInt() and 0xFF) shl 8)
|
|
495
|
+
val frameCnt = payload[2].toInt() and 0xFF
|
|
496
|
+
var offset = 4
|
|
497
|
+
val frames = mutableListOf<ByteArray>()
|
|
498
|
+
var i = 0
|
|
499
|
+
while (i < frameCnt && offset < payload.size) {
|
|
500
|
+
val frameLen = payload[offset].toInt() and 0xFF
|
|
501
|
+
offset += 1
|
|
502
|
+
if (offset + frameLen > payload.size) break
|
|
503
|
+
val body = payload.copyOfRange(offset, offset + frameLen)
|
|
504
|
+
offset += frameLen
|
|
505
|
+
i++
|
|
506
|
+
if (frameLen < 8) continue
|
|
507
|
+
val opusLen = body[3].toInt() and 0xFF
|
|
508
|
+
if (8 + opusLen > frameLen) continue
|
|
509
|
+
frames.add(body.copyOfRange(8, 8 + opusLen))
|
|
510
|
+
}
|
|
511
|
+
return Packet(type, sn, frames)
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
// ---------- Opus Decoder (MediaCodec) ----------
|
|
516
|
+
|
|
517
|
+
/**
|
|
518
|
+
* Decodes the glasses' 16 kHz mono Opus frames to 16-bit PCM via the platform
|
|
519
|
+
* MediaCodec "audio/opus" decoder, so no native Opus library has to be vendored.
|
|
520
|
+
*/
|
|
521
|
+
private class NimoOpusDecoder(
|
|
522
|
+
private val sampleRate: Int = 16_000,
|
|
523
|
+
private val channels: Int = 1
|
|
524
|
+
) {
|
|
525
|
+
private var codec: MediaCodec? = null
|
|
526
|
+
private var ptsUs: Long = 0
|
|
527
|
+
|
|
528
|
+
fun start(): Boolean {
|
|
529
|
+
if (codec != null) return true
|
|
530
|
+
return try {
|
|
531
|
+
val format =
|
|
532
|
+
MediaFormat.createAudioFormat(MediaFormat.MIMETYPE_AUDIO_OPUS, sampleRate, channels)
|
|
533
|
+
// csd-0: OpusHead identification header
|
|
534
|
+
val opusHead = ByteBuffer.allocate(19).order(ByteOrder.LITTLE_ENDIAN)
|
|
535
|
+
opusHead.put("OpusHead".toByteArray(Charsets.US_ASCII))
|
|
536
|
+
opusHead.put(1) // version
|
|
537
|
+
opusHead.put(channels.toByte())
|
|
538
|
+
opusHead.putShort(0) // pre-skip
|
|
539
|
+
opusHead.putInt(sampleRate)
|
|
540
|
+
opusHead.putShort(0) // output gain
|
|
541
|
+
opusHead.put(0) // mapping family
|
|
542
|
+
opusHead.flip()
|
|
543
|
+
format.setByteBuffer("csd-0", opusHead)
|
|
544
|
+
// csd-1/csd-2: 64-bit pre-skip and seek pre-roll in nanoseconds
|
|
545
|
+
format.setByteBuffer(
|
|
546
|
+
"csd-1",
|
|
547
|
+
ByteBuffer.allocate(8).order(ByteOrder.LITTLE_ENDIAN).putLong(0).apply { flip() }
|
|
548
|
+
)
|
|
549
|
+
format.setByteBuffer(
|
|
550
|
+
"csd-2",
|
|
551
|
+
ByteBuffer.allocate(8).order(ByteOrder.LITTLE_ENDIAN).putLong(0).apply { flip() }
|
|
552
|
+
)
|
|
553
|
+
val c = MediaCodec.createDecoderByType(MediaFormat.MIMETYPE_AUDIO_OPUS)
|
|
554
|
+
c.configure(format, null, null, 0)
|
|
555
|
+
c.start()
|
|
556
|
+
codec = c
|
|
557
|
+
ptsUs = 0
|
|
558
|
+
true
|
|
559
|
+
} catch (e: Exception) {
|
|
560
|
+
Bridge.log("NIMO: failed to start Opus decoder: ${e.message}")
|
|
561
|
+
codec = null
|
|
562
|
+
false
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
/** Decodes one Opus frame. May return empty while the codec is still buffering. */
|
|
567
|
+
fun decode(opusFrame: ByteArray): ByteArray {
|
|
568
|
+
val c = codec ?: return ByteArray(0)
|
|
569
|
+
val out = ByteArrayOutputStream()
|
|
570
|
+
try {
|
|
571
|
+
val inIdx = c.dequeueInputBuffer(10_000)
|
|
572
|
+
if (inIdx >= 0) {
|
|
573
|
+
val buf = c.getInputBuffer(inIdx) ?: return ByteArray(0)
|
|
574
|
+
buf.clear()
|
|
575
|
+
buf.put(opusFrame)
|
|
576
|
+
c.queueInputBuffer(inIdx, 0, opusFrame.size, ptsUs, 0)
|
|
577
|
+
ptsUs += 20_000 // 20 ms frames
|
|
578
|
+
}
|
|
579
|
+
val info = MediaCodec.BufferInfo()
|
|
580
|
+
var outIdx = c.dequeueOutputBuffer(info, 10_000)
|
|
581
|
+
while (outIdx >= 0) {
|
|
582
|
+
val buf = c.getOutputBuffer(outIdx)
|
|
583
|
+
if (buf != null && info.size > 0) {
|
|
584
|
+
val pcm = ByteArray(info.size)
|
|
585
|
+
buf.position(info.offset)
|
|
586
|
+
buf.get(pcm)
|
|
587
|
+
out.write(pcm)
|
|
588
|
+
}
|
|
589
|
+
c.releaseOutputBuffer(outIdx, false)
|
|
590
|
+
outIdx = c.dequeueOutputBuffer(info, 0)
|
|
591
|
+
}
|
|
592
|
+
} catch (e: Exception) {
|
|
593
|
+
Bridge.log("NIMO: Opus decode error: ${e.message}")
|
|
594
|
+
release()
|
|
595
|
+
}
|
|
596
|
+
return out.toByteArray()
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
fun release() {
|
|
600
|
+
try {
|
|
601
|
+
codec?.stop()
|
|
602
|
+
codec?.release()
|
|
603
|
+
} catch (_: Exception) {}
|
|
604
|
+
codec = null
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
// ---------- Audio Client ----------
|
|
609
|
+
|
|
610
|
+
/**
|
|
611
|
+
* Mic audio client, mirroring the vendor's GlassesAudioClient:
|
|
612
|
+
* - bounded FIFO of raw uplink packets (listener only enqueues; a sequential drain
|
|
613
|
+
* on a dedicated thread decodes one packet at a time); on overflow the oldest is
|
|
614
|
+
* dropped with a periodic warning (backpressure guard);
|
|
615
|
+
* - codec is initialized BEFORE the start command is sent (the glasses begin
|
|
616
|
+
* uplinking immediately) and on stop, processing halts and the queue is cleared
|
|
617
|
+
* BEFORE the codec is released (tail frames arrive after the stop command);
|
|
618
|
+
* - first-event diagnostics: one log each for first packet / first non-opus /
|
|
619
|
+
* first decode skip / first emitted frame, to locate where "frames=0" stalls;
|
|
620
|
+
* - frame duration varies (10 ms / 20 ms) — PCM length is never assumed.
|
|
621
|
+
*/
|
|
622
|
+
private class NimoAudioClient(
|
|
623
|
+
private val sendCommand: (ByteArray) -> Unit,
|
|
624
|
+
private val onPcm: (ByteArray) -> Unit,
|
|
625
|
+
private val onActivity: () -> Unit,
|
|
626
|
+
) {
|
|
627
|
+
companion object {
|
|
628
|
+
// Same cap as the vendor's maxPendingPackets.
|
|
629
|
+
private const val MAX_PENDING_PACKETS = 64
|
|
630
|
+
// Fall back to the other ear if the preferred one goes quiet for this long.
|
|
631
|
+
private const val SIDE_FALLBACK_MS = 2_000L
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
private var thread: HandlerThread? = null
|
|
635
|
+
private var handler: Handler? = null
|
|
636
|
+
private var decoder: NimoOpusDecoder? = null
|
|
637
|
+
@Volatile private var started = false
|
|
638
|
+
private val pendingCount = java.util.concurrent.atomic.AtomicInteger(0)
|
|
639
|
+
private var droppedCount = 0
|
|
640
|
+
private var lastRightPacketMs = 0L
|
|
641
|
+
private var preferRight = true
|
|
642
|
+
|
|
643
|
+
private var logFirstPacket = true
|
|
644
|
+
private var logFirstNonOpus = true
|
|
645
|
+
private var logFirstSkip = true
|
|
646
|
+
private var logFirstEmit = true
|
|
647
|
+
|
|
648
|
+
fun start() {
|
|
649
|
+
if (started) {
|
|
650
|
+
// Already running: just re-send the start command (idempotent on the
|
|
651
|
+
// firmware) so a watchdog-triggered re-enable can recover a real outage.
|
|
652
|
+
sendCommand(byteArrayOf(0x52, 0x01, 0x00, 0x00))
|
|
653
|
+
return
|
|
654
|
+
}
|
|
655
|
+
val t = HandlerThread("NimoAudio")
|
|
656
|
+
t.start()
|
|
657
|
+
thread = t
|
|
658
|
+
handler = Handler(t.looper)
|
|
659
|
+
decoder =
|
|
660
|
+
NimoOpusDecoder().also {
|
|
661
|
+
if (!it.start()) Bridge.log("NIMO: audio: Opus decoder failed to start")
|
|
662
|
+
}
|
|
663
|
+
logFirstPacket = true
|
|
664
|
+
logFirstNonOpus = true
|
|
665
|
+
logFirstSkip = true
|
|
666
|
+
logFirstEmit = true
|
|
667
|
+
droppedCount = 0
|
|
668
|
+
// started=true BEFORE the start command: the glasses begin uplinking the
|
|
669
|
+
// moment they receive it; otherwise the first frames would be discarded.
|
|
670
|
+
started = true
|
|
671
|
+
sendCommand(byteArrayOf(0x52, 0x01, 0x00, 0x00))
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
fun stop() {
|
|
675
|
+
if (!started) return
|
|
676
|
+
// Halt processing and drop in-flight frames BEFORE releasing the decoder —
|
|
677
|
+
// the glasses keep sending a few frames after the stop command, and decoding
|
|
678
|
+
// on a released codec would throw.
|
|
679
|
+
started = false
|
|
680
|
+
handler?.removeCallbacksAndMessages(null)
|
|
681
|
+
pendingCount.set(0)
|
|
682
|
+
sendCommand(byteArrayOf(0x52, 0x00, 0x00, 0x00))
|
|
683
|
+
val h = handler
|
|
684
|
+
val t = thread
|
|
685
|
+
val d = decoder
|
|
686
|
+
handler = null
|
|
687
|
+
thread = null
|
|
688
|
+
decoder = null
|
|
689
|
+
if (h != null) {
|
|
690
|
+
h.post {
|
|
691
|
+
d?.release()
|
|
692
|
+
t?.quitSafely()
|
|
693
|
+
}
|
|
694
|
+
} else {
|
|
695
|
+
d?.release()
|
|
696
|
+
t?.quitSafely()
|
|
697
|
+
}
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
/** Called from the GATT notify callback (binder thread): enqueue only. */
|
|
701
|
+
fun enqueue(bytes: ByteArray) {
|
|
702
|
+
if (!started) return
|
|
703
|
+
val h = handler ?: return
|
|
704
|
+
if (pendingCount.get() >= MAX_PENDING_PACKETS) {
|
|
705
|
+
droppedCount++
|
|
706
|
+
if (droppedCount == 1 || droppedCount % 50 == 0) {
|
|
707
|
+
Bridge.log(
|
|
708
|
+
"NIMO: audio backpressure: queue full ($MAX_PENDING_PACKETS), dropped packet (total=$droppedCount) — decode lagging capture"
|
|
709
|
+
)
|
|
710
|
+
}
|
|
711
|
+
return
|
|
712
|
+
}
|
|
713
|
+
pendingCount.incrementAndGet()
|
|
714
|
+
h.post {
|
|
715
|
+
pendingCount.decrementAndGet()
|
|
716
|
+
process(bytes)
|
|
717
|
+
}
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
// Flow stats: one summary line every 5s while audio streams, including the
|
|
721
|
+
// DeviceManager forwarding flags so "is audio reaching the cloud" is visible.
|
|
722
|
+
private var statsPackets = 0
|
|
723
|
+
private var statsFrames = 0
|
|
724
|
+
private var statsPcmBytes = 0
|
|
725
|
+
private var statsLastReportMs = 0L
|
|
726
|
+
|
|
727
|
+
private fun reportStats(now: Long) {
|
|
728
|
+
if (now - statsLastReportMs < 5_000) return
|
|
729
|
+
if (statsLastReportMs != 0L) {
|
|
730
|
+
val sendLc3 = DeviceStore.get("bluetooth", "should_send_lc3") as? Boolean ?: false
|
|
731
|
+
val sendPcm = DeviceStore.get("bluetooth", "should_send_pcm") as? Boolean ?: false
|
|
732
|
+
val sendTranscript =
|
|
733
|
+
DeviceStore.get("bluetooth", "should_send_transcript") as? Boolean ?: false
|
|
734
|
+
Bridge.log(
|
|
735
|
+
"NIMO: audio: flowing — $statsPackets pkts, $statsFrames frames, $statsPcmBytes PCM bytes in 5s (cloud: lc3=$sendLc3 pcm=$sendPcm transcript=$sendTranscript)"
|
|
736
|
+
)
|
|
737
|
+
}
|
|
738
|
+
statsPackets = 0
|
|
739
|
+
statsFrames = 0
|
|
740
|
+
statsPcmBytes = 0
|
|
741
|
+
statsLastReportMs = now
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
/** Runs on the audio thread, one packet at a time (sequential drain). */
|
|
745
|
+
private fun process(bytes: ByteArray) {
|
|
746
|
+
if (!started) return
|
|
747
|
+
if (logFirstPacket) {
|
|
748
|
+
logFirstPacket = false
|
|
749
|
+
Bridge.log("NIMO: audio: first uplink packet (${bytes.size} bytes)")
|
|
750
|
+
}
|
|
751
|
+
statsPackets++
|
|
752
|
+
reportStats(System.currentTimeMillis())
|
|
753
|
+
val packet = NimoAudioParser.parse(bytes)
|
|
754
|
+
if (packet == null ||
|
|
755
|
+
(packet.type != NimoAudioParser.TYPE_OPUS_LEFT &&
|
|
756
|
+
packet.type != NimoAudioParser.TYPE_OPUS_RIGHT)
|
|
757
|
+
) {
|
|
758
|
+
if (logFirstNonOpus) {
|
|
759
|
+
logFirstNonOpus = false
|
|
760
|
+
Bridge.log(
|
|
761
|
+
"NIMO: audio: first packet not an opus frame (parsed=${packet != null}, type=${packet?.type})"
|
|
762
|
+
)
|
|
763
|
+
}
|
|
764
|
+
return
|
|
765
|
+
}
|
|
766
|
+
// Any parsed opus packet counts as glasses-audio activity for the watchdog.
|
|
767
|
+
onActivity()
|
|
768
|
+
|
|
769
|
+
val now = System.currentTimeMillis()
|
|
770
|
+
when (packet.type) {
|
|
771
|
+
NimoAudioParser.TYPE_OPUS_RIGHT -> {
|
|
772
|
+
lastRightPacketMs = now
|
|
773
|
+
preferRight = true
|
|
774
|
+
}
|
|
775
|
+
NimoAudioParser.TYPE_OPUS_LEFT -> {
|
|
776
|
+
// Only fall back to the left ear when the right has gone quiet —
|
|
777
|
+
// forwarding both would duplicate the audio.
|
|
778
|
+
if (preferRight && now - lastRightPacketMs < SIDE_FALLBACK_MS) return
|
|
779
|
+
preferRight = false
|
|
780
|
+
}
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
val codec = decoder ?: return
|
|
784
|
+
for (opusFrame in packet.opusFrames) {
|
|
785
|
+
// Frame duration varies (10 ms = 160 samples, 20 ms = 320 samples at
|
|
786
|
+
// 16 kHz) — accept whatever the decoder returns, never assume a length.
|
|
787
|
+
val pcm48 = codec.decode(opusFrame)
|
|
788
|
+
if (pcm48.isEmpty()) {
|
|
789
|
+
if (logFirstSkip) {
|
|
790
|
+
logFirstSkip = false
|
|
791
|
+
Bridge.log("NIMO: audio: first frame skipped at decode (opusLen=${opusFrame.size})")
|
|
792
|
+
}
|
|
793
|
+
continue
|
|
794
|
+
}
|
|
795
|
+
// MediaCodec's Opus decoder always outputs 48 kHz regardless of the
|
|
796
|
+
// OpusHead rate; the MentraOS pipeline expects 16 kHz mono → decimate by 3.
|
|
797
|
+
val pcm16 = downsample48kTo16k(pcm48)
|
|
798
|
+
if (pcm16.isEmpty()) continue
|
|
799
|
+
if (logFirstEmit) {
|
|
800
|
+
logFirstEmit = false
|
|
801
|
+
Bridge.log("NIMO: audio: first decoded PCM frame emitted (${pcm16.size} bytes @16kHz)")
|
|
802
|
+
}
|
|
803
|
+
statsFrames++
|
|
804
|
+
statsPcmBytes += pcm16.size
|
|
805
|
+
onPcm(pcm16)
|
|
806
|
+
}
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
/** 48 kHz → 16 kHz: average each group of 3 samples (cheap low-pass + decimate). */
|
|
810
|
+
private fun downsample48kTo16k(pcm48: ByteArray): ByteArray {
|
|
811
|
+
val inSamples = pcm48.size / 2
|
|
812
|
+
val outSamples = inSamples / 3
|
|
813
|
+
if (outSamples == 0) return ByteArray(0)
|
|
814
|
+
val out = ByteArray(outSamples * 2)
|
|
815
|
+
var i = 0
|
|
816
|
+
for (o in 0 until outSamples) {
|
|
817
|
+
var sum = 0
|
|
818
|
+
for (k in 0 until 3) {
|
|
819
|
+
val lo = pcm48[i].toInt() and 0xFF
|
|
820
|
+
val hi = pcm48[i + 1].toInt() // sign-extended high byte
|
|
821
|
+
sum += (hi shl 8) or lo
|
|
822
|
+
i += 2
|
|
823
|
+
}
|
|
824
|
+
val sample = (sum / 3).coerceIn(-32768, 32767)
|
|
825
|
+
out[o * 2] = (sample and 0xFF).toByte()
|
|
826
|
+
out[o * 2 + 1] = ((sample shr 8) and 0xFF).toByte()
|
|
827
|
+
}
|
|
828
|
+
return out
|
|
829
|
+
}
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
// ---------- Reconnection Manager ----------
|
|
833
|
+
|
|
834
|
+
private class NimoReconnectionManager(
|
|
835
|
+
private val intervalMs: Long = 30_000L,
|
|
836
|
+
// First attempt comes quickly so a transient failure during pairing
|
|
837
|
+
// self-heals well inside the pairing timeout.
|
|
838
|
+
private val firstDelayMs: Long = 5_000L,
|
|
839
|
+
private val maxAttempts: Int = -1 // -1 for unlimited
|
|
840
|
+
) {
|
|
841
|
+
private val handler = Handler(Looper.getMainLooper())
|
|
842
|
+
private var runnable: Runnable? = null
|
|
843
|
+
private var attempts = 0
|
|
844
|
+
|
|
845
|
+
fun start(onAttempt: () -> Boolean) {
|
|
846
|
+
stop()
|
|
847
|
+
attempts = 0
|
|
848
|
+
val r =
|
|
849
|
+
object : Runnable {
|
|
850
|
+
override fun run() {
|
|
851
|
+
if (maxAttempts > 0 && attempts >= maxAttempts) {
|
|
852
|
+
Bridge.log("NIMO: Max reconnection attempts ($maxAttempts) reached")
|
|
853
|
+
stop()
|
|
854
|
+
return
|
|
855
|
+
}
|
|
856
|
+
attempts++
|
|
857
|
+
Bridge.log("NIMO: Reconnection attempt $attempts")
|
|
858
|
+
if (onAttempt()) {
|
|
859
|
+
Bridge.log("NIMO: Reconnection successful, stopping")
|
|
860
|
+
stop()
|
|
861
|
+
return
|
|
862
|
+
}
|
|
863
|
+
handler.postDelayed(this, intervalMs)
|
|
864
|
+
}
|
|
865
|
+
}
|
|
866
|
+
runnable = r
|
|
867
|
+
handler.postDelayed(r, firstDelayMs)
|
|
868
|
+
}
|
|
869
|
+
|
|
870
|
+
fun stop() {
|
|
871
|
+
runnable?.let { handler.removeCallbacks(it) }
|
|
872
|
+
runnable = null
|
|
873
|
+
attempts = 0
|
|
874
|
+
}
|
|
875
|
+
}
|
|
876
|
+
|
|
877
|
+
// ---------- Nimo Class ----------
|
|
878
|
+
|
|
879
|
+
class Nimo : SGCManager() {
|
|
880
|
+
|
|
881
|
+
companion object {
|
|
882
|
+
private const val PREFS_NAME = "NimoPrefs"
|
|
883
|
+
private const val KEY_LAST_ADDRESS = "nimo_lastDeviceAddress"
|
|
884
|
+
private const val KEY_LAST_NAME = "nimo_lastDeviceName"
|
|
885
|
+
|
|
886
|
+
private const val TWS_TIMEOUT_MS = 10_000L
|
|
887
|
+
private const val ACK_TIMEOUT_MS = 5_000L
|
|
888
|
+
// Generous: the glasses' classic bond can take 30-45s PER attempt and may need
|
|
889
|
+
// a retry, so this must cover several bond attempts + discovery + the 10s TWS gate.
|
|
890
|
+
private const val PAIRING_TIMEOUT_MS = 120_000L
|
|
891
|
+
// Settle time after BOND_BONDED before the first GATT connect — connecting
|
|
892
|
+
// immediately races the BR/EDR teardown + CTKD finalization (status 133).
|
|
893
|
+
// Measured: 1.5s was too short (the first connect-after-bond still hit 133 and
|
|
894
|
+
// only the 5s reconnection timer recovered it); 5s lets the first connect land.
|
|
895
|
+
private const val POST_BOND_SETTLE_MS = 5_000L
|
|
896
|
+
// The glasses' BR/EDR bond is slow and flaky — it can report BOND_NONE mid-pairing
|
|
897
|
+
// yet succeed on a retry, so don't treat the first failure as terminal.
|
|
898
|
+
private const val MAX_BOND_ATTEMPTS = 3
|
|
899
|
+
private const val BOND_RETRY_DELAY_MS = 2_000L
|
|
900
|
+
private const val BATTERY_POLL_MS = 30_000L
|
|
901
|
+
private const val TEXT_QUEUE_TICK_MS = 100L
|
|
902
|
+
private const val WRITE_WATCHDOG_MS = 1_000L
|
|
903
|
+
private const val SET_TIME_MAX_ATTEMPTS = 3
|
|
904
|
+
private const val SET_TIME_RETRY_DELAY_MS = 500L
|
|
905
|
+
}
|
|
906
|
+
|
|
907
|
+
init {
|
|
908
|
+
type = DeviceTypes.NIMO
|
|
909
|
+
hasMic = true
|
|
910
|
+
}
|
|
911
|
+
|
|
912
|
+
// The text surface every sendTextWall renders into. The ASR note view (appId 0x04,
|
|
913
|
+
// note text resId 0x00) is a plain text page that renders pushed text directly;
|
|
914
|
+
// Prompter (0x06) was tried first but did not display pushed text on hardware.
|
|
915
|
+
private val textAppId = NimoProtocol.APP_ID_ASR_NOTE
|
|
916
|
+
private val textLayoutId = 0
|
|
917
|
+
private val textResId = 0
|
|
918
|
+
|
|
919
|
+
// BLE
|
|
920
|
+
private val context: Context
|
|
921
|
+
get() = Bridge.getContext()
|
|
922
|
+
private val bluetoothAdapter: BluetoothAdapter? = BluetoothAdapter.getDefaultAdapter()
|
|
923
|
+
private var gatt: BluetoothGatt? = null
|
|
924
|
+
private var txChar: BluetoothGattCharacteristic? = null
|
|
925
|
+
private var rxChar: BluetoothGattCharacteristic? = null
|
|
926
|
+
private var micChar: BluetoothGattCharacteristic? = null
|
|
927
|
+
private var isDisconnecting = false
|
|
928
|
+
private var negotiatedMtu = 23
|
|
929
|
+
|
|
930
|
+
// Device search
|
|
931
|
+
private var DEVICE_SEARCH_ID = "NOT_SET"
|
|
932
|
+
|
|
933
|
+
private var lastDeviceAddress: String?
|
|
934
|
+
get() =
|
|
935
|
+
context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
|
936
|
+
.getString(KEY_LAST_ADDRESS, null)
|
|
937
|
+
set(value) {
|
|
938
|
+
context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE).edit().apply {
|
|
939
|
+
if (value != null) putString(KEY_LAST_ADDRESS, value) else remove(KEY_LAST_ADDRESS)
|
|
940
|
+
apply()
|
|
941
|
+
}
|
|
942
|
+
}
|
|
943
|
+
|
|
944
|
+
private var lastDeviceName: String?
|
|
945
|
+
get() =
|
|
946
|
+
context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
|
947
|
+
.getString(KEY_LAST_NAME, null)
|
|
948
|
+
set(value) {
|
|
949
|
+
context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE).edit().apply {
|
|
950
|
+
if (value != null) putString(KEY_LAST_NAME, value) else remove(KEY_LAST_NAME)
|
|
951
|
+
apply()
|
|
952
|
+
}
|
|
953
|
+
}
|
|
954
|
+
|
|
955
|
+
private val reconnectionManager = NimoReconnectionManager()
|
|
956
|
+
private val receiveAssembler = NimoReceiveAssembler()
|
|
957
|
+
private val mainHandler = Handler(Looper.getMainLooper())
|
|
958
|
+
|
|
959
|
+
// Handshake
|
|
960
|
+
private enum class HandshakeState {
|
|
961
|
+
IDLE,
|
|
962
|
+
AWAITING_TWS,
|
|
963
|
+
AWAITING_TIME_ACK,
|
|
964
|
+
READY
|
|
965
|
+
}
|
|
966
|
+
private var handshakeState = HandshakeState.IDLE
|
|
967
|
+
private var twsConnected = false
|
|
968
|
+
private var twsTimeoutRunnable: Runnable? = null
|
|
969
|
+
private var pairingTimeoutRunnable: Runnable? = null
|
|
970
|
+
|
|
971
|
+
// Pending acks keyed by (cmd shl 8) or key
|
|
972
|
+
private class PendingAck(val onResult: (Boolean) -> Unit, val timeout: Runnable)
|
|
973
|
+
private val pendingAcks = mutableMapOf<Int, PendingAck>()
|
|
974
|
+
|
|
975
|
+
// Serialized write queue: Android allows one in-flight GATT op; WRITE_TYPE_DEFAULT also
|
|
976
|
+
// gives us long-writes when a 509-byte frame exceeds the negotiated MTU.
|
|
977
|
+
private class QueuedWrite(val char: BluetoothGattCharacteristic, val bytes: ByteArray)
|
|
978
|
+
private val writeQueue = ArrayDeque<QueuedWrite>()
|
|
979
|
+
private var writeInFlight = false
|
|
980
|
+
private var writeWatchdog: Runnable? = null
|
|
981
|
+
|
|
982
|
+
// Heartbeat / text queue
|
|
983
|
+
private var batteryPollRunnable: Runnable? = null
|
|
984
|
+
private var textQueueRunnable: Runnable? = null
|
|
985
|
+
private var pendingText: String? = null
|
|
986
|
+
private var textAppEntered = false
|
|
987
|
+
private var navAppEntered = false
|
|
988
|
+
private var currentGlassesAppId = -1
|
|
989
|
+
|
|
990
|
+
// Battery
|
|
991
|
+
private var lastBatteryLevel = -1
|
|
992
|
+
private var lastCharging = false
|
|
993
|
+
|
|
994
|
+
// Version info
|
|
995
|
+
private var firmwareVersionPacked: String = ""
|
|
996
|
+
private var firmwareVersionDetail: String = ""
|
|
997
|
+
|
|
998
|
+
// Mic audio (vendor GlassesAudioClient structure)
|
|
999
|
+
private val audioClient =
|
|
1000
|
+
NimoAudioClient(
|
|
1001
|
+
sendCommand = { bytes -> micChar?.let { enqueueWrite(it, bytes) } },
|
|
1002
|
+
onPcm = { pcm -> DeviceManager.getInstance().handlePcm(pcm) },
|
|
1003
|
+
onActivity = { DeviceManager.getInstance().reportGlassesAudioActivity() }
|
|
1004
|
+
)
|
|
1005
|
+
|
|
1006
|
+
// ---------- SGCManager: Connection Management ----------
|
|
1007
|
+
|
|
1008
|
+
override fun findCompatibleDevices() {
|
|
1009
|
+
Bridge.log("NIMO: findCompatibleDevices()")
|
|
1010
|
+
DEVICE_SEARCH_ID = "NOT_SET"
|
|
1011
|
+
DeviceStore.apply("glasses", "connectionState", ConnTypes.SCANNING)
|
|
1012
|
+
startScan()
|
|
1013
|
+
}
|
|
1014
|
+
|
|
1015
|
+
override fun connectById(id: String) {
|
|
1016
|
+
Bridge.log("NIMO: connectById($id)")
|
|
1017
|
+
DEVICE_SEARCH_ID = id
|
|
1018
|
+
DeviceStore.apply("glasses", "connectionState", ConnTypes.CONNECTING)
|
|
1019
|
+
isDisconnecting = false
|
|
1020
|
+
bondAttempts = 0
|
|
1021
|
+
startPairingTimeout()
|
|
1022
|
+
// Bonded target → connect directly, WITHOUT starting classic discovery.
|
|
1023
|
+
// Inquiry monopolizes the BR/EDR radio and kills an in-flight GATT page
|
|
1024
|
+
// (status 133), so discovery must only run when the device is unknown.
|
|
1025
|
+
if (connectToBondedTarget()) return
|
|
1026
|
+
if (connectByAddress()) return
|
|
1027
|
+
startScan()
|
|
1028
|
+
}
|
|
1029
|
+
|
|
1030
|
+
private fun connectToBondedTarget(): Boolean {
|
|
1031
|
+
val adapter = bluetoothAdapter ?: return false
|
|
1032
|
+
val target =
|
|
1033
|
+
try {
|
|
1034
|
+
adapter.bondedDevices?.firstOrNull { it.name == DEVICE_SEARCH_ID }
|
|
1035
|
+
} catch (e: SecurityException) {
|
|
1036
|
+
null
|
|
1037
|
+
}
|
|
1038
|
+
?: return false
|
|
1039
|
+
Bridge.log("NIMO: target is bonded — connecting directly (no discovery)")
|
|
1040
|
+
lastDeviceName = target.name
|
|
1041
|
+
lastDeviceAddress = target.address
|
|
1042
|
+
mainHandler.post { connectGattLe(target) }
|
|
1043
|
+
return true
|
|
1044
|
+
}
|
|
1045
|
+
|
|
1046
|
+
override fun stopScan() {
|
|
1047
|
+
try {
|
|
1048
|
+
bluetoothAdapter?.cancelDiscovery()
|
|
1049
|
+
} catch (_: Exception) {}
|
|
1050
|
+
discoveryReceiver?.let {
|
|
1051
|
+
try {
|
|
1052
|
+
context.unregisterReceiver(it)
|
|
1053
|
+
} catch (_: Exception) {}
|
|
1054
|
+
}
|
|
1055
|
+
discoveryReceiver = null
|
|
1056
|
+
}
|
|
1057
|
+
|
|
1058
|
+
override fun disconnect() {
|
|
1059
|
+
Bridge.log("NIMO: disconnect()")
|
|
1060
|
+
isDisconnecting = true
|
|
1061
|
+
cancelPairingTimeout()
|
|
1062
|
+
cancelTwsTimeout()
|
|
1063
|
+
stopScan()
|
|
1064
|
+
unregisterBondReceiver()
|
|
1065
|
+
stopTimers()
|
|
1066
|
+
reconnectionManager.stop()
|
|
1067
|
+
failAllPendingAcks()
|
|
1068
|
+
|
|
1069
|
+
gatt?.disconnect()
|
|
1070
|
+
gatt?.close()
|
|
1071
|
+
gatt = null
|
|
1072
|
+
txChar = null
|
|
1073
|
+
rxChar = null
|
|
1074
|
+
micChar = null
|
|
1075
|
+
resetSessionState()
|
|
1076
|
+
|
|
1077
|
+
DeviceStore.apply("glasses", "connected", false)
|
|
1078
|
+
DeviceStore.apply("glasses", "fullyBooted", false)
|
|
1079
|
+
DeviceStore.apply("glasses", "connectionState", ConnTypes.DISCONNECTED)
|
|
1080
|
+
}
|
|
1081
|
+
|
|
1082
|
+
override fun forget() {
|
|
1083
|
+
Bridge.log("NIMO: forget()")
|
|
1084
|
+
disconnect()
|
|
1085
|
+
lastDeviceAddress = null
|
|
1086
|
+
lastDeviceName = null
|
|
1087
|
+
DEVICE_SEARCH_ID = "NOT_SET"
|
|
1088
|
+
}
|
|
1089
|
+
|
|
1090
|
+
override fun cleanup() {
|
|
1091
|
+
disconnect()
|
|
1092
|
+
audioClient.stop()
|
|
1093
|
+
}
|
|
1094
|
+
|
|
1095
|
+
override fun getConnectedBluetoothName(): String {
|
|
1096
|
+
return gatt?.device?.name ?: ""
|
|
1097
|
+
}
|
|
1098
|
+
|
|
1099
|
+
override fun ping() {
|
|
1100
|
+
sendFrame(NimoFrameCodec.encodeFrame(NimoProtocol.CMD_GET_PARAMETER, NimoProtocol.GET_TWS_STATUS))
|
|
1101
|
+
}
|
|
1102
|
+
|
|
1103
|
+
override fun dbg1() {}
|
|
1104
|
+
override fun dbg2() {}
|
|
1105
|
+
|
|
1106
|
+
// ---------- SGCManager: Audio Control ----------
|
|
1107
|
+
|
|
1108
|
+
override fun setMicEnabled(enabled: Boolean) {
|
|
1109
|
+
Bridge.log("NIMO: setMicEnabled($enabled)")
|
|
1110
|
+
if (micChar == null) {
|
|
1111
|
+
Bridge.log("NIMO: mic characteristic not available")
|
|
1112
|
+
return
|
|
1113
|
+
}
|
|
1114
|
+
DeviceStore.apply("glasses", "micEnabled", enabled)
|
|
1115
|
+
if (enabled) {
|
|
1116
|
+
audioClient.start()
|
|
1117
|
+
} else {
|
|
1118
|
+
audioClient.stop()
|
|
1119
|
+
}
|
|
1120
|
+
}
|
|
1121
|
+
|
|
1122
|
+
override fun sortMicRanking(list: MutableList<String>): MutableList<String> {
|
|
1123
|
+
return list
|
|
1124
|
+
}
|
|
1125
|
+
|
|
1126
|
+
// ---------- SGCManager: Display Control ----------
|
|
1127
|
+
|
|
1128
|
+
override fun setBrightness(level: Int, autoMode: Boolean) {
|
|
1129
|
+
Bridge.log("NIMO: setBrightness($level, auto=$autoMode)")
|
|
1130
|
+
sendFrame(
|
|
1131
|
+
NimoFrameCodec.encodeFrame(
|
|
1132
|
+
NimoProtocol.CMD_SET_PARAMETER,
|
|
1133
|
+
NimoProtocol.SET_AUTO_BRIGHTNESS,
|
|
1134
|
+
byteArrayOf(if (autoMode) 1 else 0)
|
|
1135
|
+
)
|
|
1136
|
+
)
|
|
1137
|
+
if (!autoMode) {
|
|
1138
|
+
// The firmware takes a 0–16 level, not a 0–100 percent.
|
|
1139
|
+
val lvl =
|
|
1140
|
+
Math.round(level.coerceIn(0, 100) / 100.0 * NimoProtocol.MAX_BRIGHTNESS_LEVEL)
|
|
1141
|
+
.toInt()
|
|
1142
|
+
sendFrame(
|
|
1143
|
+
NimoFrameCodec.encodeFrame(
|
|
1144
|
+
NimoProtocol.CMD_SET_PARAMETER,
|
|
1145
|
+
NimoProtocol.SET_BRIGHTNESS,
|
|
1146
|
+
byteArrayOf(lvl.toByte())
|
|
1147
|
+
)
|
|
1148
|
+
)
|
|
1149
|
+
}
|
|
1150
|
+
}
|
|
1151
|
+
|
|
1152
|
+
override fun clearDisplay() {
|
|
1153
|
+
// Keep the text app/page alive — a blank update avoids re-entering the app next time.
|
|
1154
|
+
sendTextWall(" ")
|
|
1155
|
+
}
|
|
1156
|
+
|
|
1157
|
+
override fun sendText(text: String) {
|
|
1158
|
+
// Coalesced: only the most recent pending text survives until the next 100ms drain.
|
|
1159
|
+
Bridge.log("NIMO: sendText(text=$text)")
|
|
1160
|
+
pendingText = text
|
|
1161
|
+
}
|
|
1162
|
+
|
|
1163
|
+
override fun sendTextWall(text: String) {
|
|
1164
|
+
// Coalesced: only the most recent pending text survives until the next 100ms drain.
|
|
1165
|
+
Bridge.log("NIMO: sendTextWall(text=$text)")
|
|
1166
|
+
pendingText = text
|
|
1167
|
+
}
|
|
1168
|
+
|
|
1169
|
+
override fun sendDoubleTextWall(top: String, bottom: String) {
|
|
1170
|
+
sendTextWall(top + "\n\n" + bottom)
|
|
1171
|
+
}
|
|
1172
|
+
|
|
1173
|
+
override fun sendPositionedText(
|
|
1174
|
+
text: String,
|
|
1175
|
+
x: Int,
|
|
1176
|
+
y: Int,
|
|
1177
|
+
width: Int,
|
|
1178
|
+
height: Int,
|
|
1179
|
+
borderWidth: Int,
|
|
1180
|
+
borderRadius: Int
|
|
1181
|
+
) {
|
|
1182
|
+
// Navigation pushes turn text via positioned_text. Nimo widgets have fixed geometry, so
|
|
1183
|
+
// position/border are ignored — funnel the text through the same coalesced path as
|
|
1184
|
+
// sendTextWall so it renders on the ASR note page. Without this override the base no-op
|
|
1185
|
+
// silently dropped all navigation text.
|
|
1186
|
+
// Bridge.log("NIMO: sendPositionedText(text=$text)")
|
|
1187
|
+
// pendingText = text
|
|
1188
|
+
}
|
|
1189
|
+
|
|
1190
|
+
override fun displayBitmap(
|
|
1191
|
+
base64ImageData: String,
|
|
1192
|
+
x: Int?,
|
|
1193
|
+
y: Int?,
|
|
1194
|
+
width: Int?,
|
|
1195
|
+
height: Int?
|
|
1196
|
+
): Boolean {
|
|
1197
|
+
// Nimo widgets have fixed geometry; x/y are not honored. Renders into the navigation
|
|
1198
|
+
// large-map widget (appId 0x01, resId 0x05, 452x170 2bpp) — the full-width nav map,
|
|
1199
|
+
// which shows far more than the small 160x160 mini-map (resId 0x00). bitmapToGrayscale
|
|
1200
|
+
// aspect-fits onto black, so a non-matching source aspect letterboxes rather than
|
|
1201
|
+
// distorts. The nav app must be foregrounded before pushing content (mirrors the text
|
|
1202
|
+
// path). TODO: hardware-verify the large-map widget renders and the nav app-mode.
|
|
1203
|
+
val targetWidth = NimoProtocol.NAV_LARGE_MAP_WIDTH
|
|
1204
|
+
val targetHeight = NimoProtocol.NAV_LARGE_MAP_HEIGHT
|
|
1205
|
+
Bridge.log("NIMO: displayBitmap → nav large map (navAppEntered=$navAppEntered)")
|
|
1206
|
+
return try {
|
|
1207
|
+
val imageBytes = Base64.decode(base64ImageData, Base64.DEFAULT)
|
|
1208
|
+
val bitmap =
|
|
1209
|
+
BitmapFactory.decodeByteArray(imageBytes, 0, imageBytes.size) ?: return false
|
|
1210
|
+
val grayscale = bitmapToGrayscale(bitmap, targetWidth, targetHeight)
|
|
1211
|
+
val packed = packL8To2bpp(grayscale)
|
|
1212
|
+
val (payload, compression) = compressAdaptive(packed)
|
|
1213
|
+
if (!navAppEntered) {
|
|
1214
|
+
sendFrame(
|
|
1215
|
+
NimoFrameCodec.encodeFrame(
|
|
1216
|
+
NimoProtocol.CMD_CONTROL_INSTRUCTION,
|
|
1217
|
+
NimoProtocol.CTRL_ENTER_APP,
|
|
1218
|
+
byteArrayOf(
|
|
1219
|
+
NimoProtocol.APP_ID_NAV.toByte(),
|
|
1220
|
+
NimoProtocol.APP_MODE_STANDALONE.toByte()
|
|
1221
|
+
)
|
|
1222
|
+
)
|
|
1223
|
+
)
|
|
1224
|
+
// Optimistic; corrected by app-state reports if the glasses refuse/exit.
|
|
1225
|
+
navAppEntered = true
|
|
1226
|
+
}
|
|
1227
|
+
val content =
|
|
1228
|
+
NimoFrameCodec.imageHeader(
|
|
1229
|
+
width = targetWidth,
|
|
1230
|
+
height = targetHeight,
|
|
1231
|
+
formatBpp = NimoProtocol.FORMAT_2BPP,
|
|
1232
|
+
compression = compression,
|
|
1233
|
+
originalSize = packed.size,
|
|
1234
|
+
compressedSize =
|
|
1235
|
+
if (compression == NimoProtocol.COMPRESSION_NONE) 0
|
|
1236
|
+
else payload.size
|
|
1237
|
+
) + payload
|
|
1238
|
+
val frames =
|
|
1239
|
+
NimoFrameCodec.updateContentFrames(
|
|
1240
|
+
appId = NimoProtocol.APP_ID_NAV,
|
|
1241
|
+
layoutId = 0,
|
|
1242
|
+
resId = NimoProtocol.NAV_RES_LARGE_MAP,
|
|
1243
|
+
resType = NimoProtocol.WIDGET_PICTURE,
|
|
1244
|
+
content = content
|
|
1245
|
+
)
|
|
1246
|
+
enqueueFrames(frames)
|
|
1247
|
+
true
|
|
1248
|
+
} catch (e: Exception) {
|
|
1249
|
+
Bridge.log("NIMO: displayBitmap failed: ${e.message}")
|
|
1250
|
+
false
|
|
1251
|
+
}
|
|
1252
|
+
}
|
|
1253
|
+
|
|
1254
|
+
override fun showDashboard() {
|
|
1255
|
+
Bridge.log("NIMO: showDashboard()")
|
|
1256
|
+
textAppEntered = false
|
|
1257
|
+
navAppEntered = false
|
|
1258
|
+
sendFrame(
|
|
1259
|
+
NimoFrameCodec.encodeFrame(
|
|
1260
|
+
NimoProtocol.CMD_CONTROL_INSTRUCTION,
|
|
1261
|
+
NimoProtocol.CTRL_ENTER_APP,
|
|
1262
|
+
byteArrayOf(
|
|
1263
|
+
NimoProtocol.APP_ID_DASHBOARD.toByte(),
|
|
1264
|
+
NimoProtocol.APP_MODE_STANDALONE.toByte()
|
|
1265
|
+
)
|
|
1266
|
+
)
|
|
1267
|
+
)
|
|
1268
|
+
}
|
|
1269
|
+
|
|
1270
|
+
override fun setDashboardPosition(height: Int, depth: Int) {
|
|
1271
|
+
Bridge.log("NIMO: setDashboardPosition($height, $depth)")
|
|
1272
|
+
sendFrame(
|
|
1273
|
+
NimoFrameCodec.encodeFrame(
|
|
1274
|
+
NimoProtocol.CMD_SET_PARAMETER,
|
|
1275
|
+
NimoProtocol.SET_HEIGHT_LEVEL,
|
|
1276
|
+
byteArrayOf(height.coerceIn(0, 10).toByte())
|
|
1277
|
+
)
|
|
1278
|
+
)
|
|
1279
|
+
sendFrame(
|
|
1280
|
+
NimoFrameCodec.encodeFrame(
|
|
1281
|
+
NimoProtocol.CMD_SET_PARAMETER,
|
|
1282
|
+
NimoProtocol.SET_DISTANCE,
|
|
1283
|
+
byteArrayOf(depth.coerceIn(0, 10).toByte())
|
|
1284
|
+
)
|
|
1285
|
+
)
|
|
1286
|
+
}
|
|
1287
|
+
|
|
1288
|
+
// ---------- SGCManager: Device Control ----------
|
|
1289
|
+
|
|
1290
|
+
override fun setHeadUpAngle(angle: Int) {
|
|
1291
|
+
val clamped = angle.coerceIn(0, 90)
|
|
1292
|
+
Bridge.log("NIMO: setHeadUpAngle($clamped)")
|
|
1293
|
+
// Enable the head-up display gesture, then set the wake angle.
|
|
1294
|
+
sendFrame(
|
|
1295
|
+
NimoFrameCodec.encodeFrame(
|
|
1296
|
+
NimoProtocol.CMD_SET_PARAMETER,
|
|
1297
|
+
NimoProtocol.SET_HEADUP_DISPLAY,
|
|
1298
|
+
byteArrayOf(1)
|
|
1299
|
+
)
|
|
1300
|
+
)
|
|
1301
|
+
// setAngle payload is [optType, deg]. TODO: hardware-verify optType semantics.
|
|
1302
|
+
sendFrame(
|
|
1303
|
+
NimoFrameCodec.encodeFrame(
|
|
1304
|
+
NimoProtocol.CMD_SET_PARAMETER,
|
|
1305
|
+
NimoProtocol.SET_ANGLE,
|
|
1306
|
+
byteArrayOf(0x01, clamped.toByte())
|
|
1307
|
+
)
|
|
1308
|
+
)
|
|
1309
|
+
}
|
|
1310
|
+
|
|
1311
|
+
override fun getBatteryStatus() {
|
|
1312
|
+
sendFrame(NimoFrameCodec.encodeFrame(NimoProtocol.CMD_GET_PARAMETER, NimoProtocol.GET_BATTERY))
|
|
1313
|
+
}
|
|
1314
|
+
|
|
1315
|
+
override fun setSilentMode(enabled: Boolean) {
|
|
1316
|
+
Bridge.log("NIMO: setSilentMode($enabled)")
|
|
1317
|
+
// TODO: hardware-verify that display-off matches MentraOS silent-mode semantics.
|
|
1318
|
+
sendFrame(
|
|
1319
|
+
NimoFrameCodec.encodeFrame(
|
|
1320
|
+
NimoProtocol.CMD_SET_PARAMETER,
|
|
1321
|
+
NimoProtocol.SET_DISPLAY_OFF,
|
|
1322
|
+
byteArrayOf(if (enabled) 1 else 0)
|
|
1323
|
+
)
|
|
1324
|
+
)
|
|
1325
|
+
}
|
|
1326
|
+
|
|
1327
|
+
override fun exit() {
|
|
1328
|
+
Bridge.log("NIMO: exit()")
|
|
1329
|
+
val appId = if (currentGlassesAppId >= 0) currentGlassesAppId else textAppId
|
|
1330
|
+
textAppEntered = false
|
|
1331
|
+
navAppEntered = false
|
|
1332
|
+
sendFrame(
|
|
1333
|
+
NimoFrameCodec.encodeFrame(
|
|
1334
|
+
NimoProtocol.CMD_CONTROL_INSTRUCTION,
|
|
1335
|
+
NimoProtocol.CTRL_QUIT_APP,
|
|
1336
|
+
byteArrayOf(appId.toByte())
|
|
1337
|
+
)
|
|
1338
|
+
)
|
|
1339
|
+
}
|
|
1340
|
+
|
|
1341
|
+
override fun sendShutdown() {
|
|
1342
|
+
Bridge.log("NIMO: sendShutdown - not supported")
|
|
1343
|
+
}
|
|
1344
|
+
|
|
1345
|
+
override fun sendReboot() {
|
|
1346
|
+
Bridge.log("NIMO: sendReboot - not supported")
|
|
1347
|
+
}
|
|
1348
|
+
|
|
1349
|
+
override fun sendRgbLedControl(
|
|
1350
|
+
requestId: String,
|
|
1351
|
+
packageName: String?,
|
|
1352
|
+
action: String,
|
|
1353
|
+
color: String?,
|
|
1354
|
+
onDurationMs: Int,
|
|
1355
|
+
offDurationMs: Int,
|
|
1356
|
+
count: Int
|
|
1357
|
+
) {
|
|
1358
|
+
Bridge.sendRgbLedControlResponse(requestId, false, "device_not_supported")
|
|
1359
|
+
}
|
|
1360
|
+
|
|
1361
|
+
// ---------- Notifications ----------
|
|
1362
|
+
|
|
1363
|
+
/**
|
|
1364
|
+
* Pushes a notification (cmd=0x09 key=0x01, fixed 319-byte struct:
|
|
1365
|
+
* appId(1) + title(51, UTF-8 zero-padded) + time(9) + contentLen(2 LE) + content(256)).
|
|
1366
|
+
*/
|
|
1367
|
+
fun sendNotification(notificationAppId: Int, title: String, content: String) {
|
|
1368
|
+
val payload = ByteArray(319)
|
|
1369
|
+
payload[0] = notificationAppId.coerceIn(0, 255).toByte()
|
|
1370
|
+
writeUtf8Padded(payload, 1, title, 51)
|
|
1371
|
+
NimoFrameCodec.encodeDeviceTime().copyInto(payload, 52)
|
|
1372
|
+
val contentBytes = content.toByteArray(Charsets.UTF_8)
|
|
1373
|
+
val clen = minOf(contentBytes.size, 256)
|
|
1374
|
+
payload[61] = (clen and 0xFF).toByte()
|
|
1375
|
+
payload[62] = ((clen shr 8) and 0xFF).toByte()
|
|
1376
|
+
writeUtf8Padded(payload, 63, content, 256)
|
|
1377
|
+
sendFrame(
|
|
1378
|
+
NimoFrameCodec.encodeFrame(
|
|
1379
|
+
NimoProtocol.CMD_CONTROL_NOTIFICATION,
|
|
1380
|
+
NimoProtocol.NOTIFICATION_SEND,
|
|
1381
|
+
payload
|
|
1382
|
+
)
|
|
1383
|
+
)
|
|
1384
|
+
}
|
|
1385
|
+
|
|
1386
|
+
private fun writeUtf8Padded(dest: ByteArray, offset: Int, text: String, maxLen: Int) {
|
|
1387
|
+
var bytes = text.toByteArray(Charsets.UTF_8)
|
|
1388
|
+
if (bytes.size > maxLen) {
|
|
1389
|
+
// Truncate on a UTF-8 boundary
|
|
1390
|
+
var end = maxLen
|
|
1391
|
+
while (end > 0 && (bytes[end].toInt() and 0xC0) == 0x80) end--
|
|
1392
|
+
bytes = bytes.copyOfRange(0, end)
|
|
1393
|
+
}
|
|
1394
|
+
bytes.copyInto(dest, offset)
|
|
1395
|
+
}
|
|
1396
|
+
|
|
1397
|
+
// ---------- SGCManager: Camera & Media (no camera) ----------
|
|
1398
|
+
|
|
1399
|
+
override fun requestPhoto(request: PhotoRequest) {
|
|
1400
|
+
Bridge.log("NIMO: requestPhoto - not supported (no camera)")
|
|
1401
|
+
}
|
|
1402
|
+
|
|
1403
|
+
override fun startStream(message: MutableMap<String, Any>) {
|
|
1404
|
+
Bridge.log("NIMO: startStream - not supported")
|
|
1405
|
+
}
|
|
1406
|
+
|
|
1407
|
+
override fun stopStream() {
|
|
1408
|
+
Bridge.log("NIMO: stopStream - not supported")
|
|
1409
|
+
}
|
|
1410
|
+
|
|
1411
|
+
override fun sendStreamKeepAlive(message: MutableMap<String, Any>) {
|
|
1412
|
+
Bridge.log("NIMO: sendStreamKeepAlive - not supported")
|
|
1413
|
+
}
|
|
1414
|
+
|
|
1415
|
+
override fun startVideoRecording(requestId: String, save: Boolean, sound: Boolean) {
|
|
1416
|
+
Bridge.log("NIMO: startVideoRecording - not supported")
|
|
1417
|
+
}
|
|
1418
|
+
|
|
1419
|
+
override fun stopVideoRecording(requestId: String) {
|
|
1420
|
+
Bridge.log("NIMO: stopVideoRecording - not supported")
|
|
1421
|
+
}
|
|
1422
|
+
|
|
1423
|
+
override fun sendButtonPhotoSettings() {}
|
|
1424
|
+
override fun sendButtonVideoRecordingSettings() {}
|
|
1425
|
+
override fun sendButtonMaxRecordingTime() {}
|
|
1426
|
+
override fun sendCameraFovSetting() {}
|
|
1427
|
+
|
|
1428
|
+
// ---------- SGCManager: Network (no WiFi) ----------
|
|
1429
|
+
|
|
1430
|
+
override fun requestWifiScan() {}
|
|
1431
|
+
override fun sendWifiCredentials(ssid: String, password: String) {}
|
|
1432
|
+
override fun forgetWifiNetwork(ssid: String) {}
|
|
1433
|
+
override fun sendHotspotState(enabled: Boolean) {}
|
|
1434
|
+
|
|
1435
|
+
// ---------- SGCManager: User Context / Gallery / Version ----------
|
|
1436
|
+
|
|
1437
|
+
override fun sendUserEmailToGlasses(email: String) {}
|
|
1438
|
+
override fun sendIncidentId(incidentId: String, apiBaseUrl: String?) {}
|
|
1439
|
+
override fun queryGalleryStatus() {}
|
|
1440
|
+
override fun sendGalleryMode() {}
|
|
1441
|
+
|
|
1442
|
+
override fun requestVersionInfo() {
|
|
1443
|
+
sendFrame(NimoFrameCodec.encodeFrame(NimoProtocol.CMD_GET_PARAMETER, NimoProtocol.GET_VERSION))
|
|
1444
|
+
sendFrame(
|
|
1445
|
+
NimoFrameCodec.encodeFrame(
|
|
1446
|
+
NimoProtocol.CMD_GET_PARAMETER,
|
|
1447
|
+
NimoProtocol.GET_VERSION_DETAIL
|
|
1448
|
+
)
|
|
1449
|
+
)
|
|
1450
|
+
}
|
|
1451
|
+
|
|
1452
|
+
// ---------- Device Discovery (classic Bluetooth — mirrors the vendor transport) ----------
|
|
1453
|
+
//
|
|
1454
|
+
// The MAIN Nimo device does not BLE-advertise under its name; only the "<name>_BLE"
|
|
1455
|
+
// ANCS side channel advertises, and that device has no data service. The vendor's
|
|
1456
|
+
// Android transport therefore discovers the main device over CLASSIC Bluetooth
|
|
1457
|
+
// (bonded list + discovery) and then connects BLE GATT to the same address
|
|
1458
|
+
// (dual-mode chip, shared MAC).
|
|
1459
|
+
|
|
1460
|
+
private fun isNimoMainDevice(name: String): Boolean {
|
|
1461
|
+
val lower = name.lowercase()
|
|
1462
|
+
return lower.startsWith(NimoBLE.NAME_PREFIX) && !lower.endsWith(NimoBLE.BLE_NAME_SUFFIX)
|
|
1463
|
+
}
|
|
1464
|
+
|
|
1465
|
+
private var discoveryReceiver: android.content.BroadcastReceiver? = null
|
|
1466
|
+
|
|
1467
|
+
private fun startScan(): Boolean {
|
|
1468
|
+
Bridge.log("NIMO: startScan() (classic discovery)")
|
|
1469
|
+
stopScan()
|
|
1470
|
+
|
|
1471
|
+
val adapter =
|
|
1472
|
+
bluetoothAdapter
|
|
1473
|
+
?: run {
|
|
1474
|
+
Bridge.log("NIMO: BluetoothAdapter not available")
|
|
1475
|
+
return false
|
|
1476
|
+
}
|
|
1477
|
+
if (!adapter.isEnabled) {
|
|
1478
|
+
Bridge.log("NIMO: Bluetooth not enabled")
|
|
1479
|
+
return false
|
|
1480
|
+
}
|
|
1481
|
+
|
|
1482
|
+
// 1) Bonded devices first: Android does not re-discover already-paired devices.
|
|
1483
|
+
try {
|
|
1484
|
+
for (device in adapter.bondedDevices ?: emptySet()) {
|
|
1485
|
+
val name = device.name ?: continue
|
|
1486
|
+
if (!isNimoMainDevice(name)) continue
|
|
1487
|
+
Bridge.log("NIMO: bonded Nimo device: $name (${device.address})")
|
|
1488
|
+
mainHandler.post { onDeviceFound(device, name) }
|
|
1489
|
+
}
|
|
1490
|
+
} catch (e: SecurityException) {
|
|
1491
|
+
Bridge.log("NIMO: bondedDevices SecurityException: ${e.message}")
|
|
1492
|
+
}
|
|
1493
|
+
|
|
1494
|
+
// 2) Classic discovery for unbonded glasses.
|
|
1495
|
+
val receiver =
|
|
1496
|
+
object : android.content.BroadcastReceiver() {
|
|
1497
|
+
@Suppress("deprecation")
|
|
1498
|
+
override fun onReceive(ctx: Context?, intent: android.content.Intent?) {
|
|
1499
|
+
if (intent?.action != android.bluetooth.BluetoothDevice.ACTION_FOUND) return
|
|
1500
|
+
val device: android.bluetooth.BluetoothDevice =
|
|
1501
|
+
intent.getParcelableExtra(
|
|
1502
|
+
android.bluetooth.BluetoothDevice.EXTRA_DEVICE
|
|
1503
|
+
)
|
|
1504
|
+
?: return
|
|
1505
|
+
val name =
|
|
1506
|
+
try {
|
|
1507
|
+
device.name
|
|
1508
|
+
} catch (e: SecurityException) {
|
|
1509
|
+
null
|
|
1510
|
+
}
|
|
1511
|
+
?: intent.getStringExtra(
|
|
1512
|
+
android.bluetooth.BluetoothDevice.EXTRA_NAME
|
|
1513
|
+
)
|
|
1514
|
+
?: return
|
|
1515
|
+
if (!isNimoMainDevice(name)) return
|
|
1516
|
+
mainHandler.post { onDeviceFound(device, name) }
|
|
1517
|
+
}
|
|
1518
|
+
}
|
|
1519
|
+
discoveryReceiver = receiver
|
|
1520
|
+
context.registerReceiver(
|
|
1521
|
+
receiver,
|
|
1522
|
+
android.content.IntentFilter(android.bluetooth.BluetoothDevice.ACTION_FOUND)
|
|
1523
|
+
)
|
|
1524
|
+
return try {
|
|
1525
|
+
val started = adapter.startDiscovery()
|
|
1526
|
+
Bridge.log("NIMO: classic discovery started: $started")
|
|
1527
|
+
true
|
|
1528
|
+
} catch (e: SecurityException) {
|
|
1529
|
+
Bridge.log("NIMO: startDiscovery SecurityException — bluetooth permission missing: ${e.message}")
|
|
1530
|
+
false
|
|
1531
|
+
} catch (e: Exception) {
|
|
1532
|
+
Bridge.log("NIMO: startDiscovery failed: ${e.message}")
|
|
1533
|
+
false
|
|
1534
|
+
}
|
|
1535
|
+
}
|
|
1536
|
+
|
|
1537
|
+
private fun onDeviceFound(device: android.bluetooth.BluetoothDevice, name: String) {
|
|
1538
|
+
Bridge.sendDiscoveredDevice(DeviceTypes.NIMO, name, device.address ?: "")
|
|
1539
|
+
|
|
1540
|
+
if (DEVICE_SEARCH_ID == "NOT_SET") return
|
|
1541
|
+
if (name != DEVICE_SEARCH_ID) return
|
|
1542
|
+
if (gatt != null) return
|
|
1543
|
+
|
|
1544
|
+
Bridge.log("NIMO: Connecting to $name (${device.address})")
|
|
1545
|
+
stopScan()
|
|
1546
|
+
lastDeviceName = name
|
|
1547
|
+
lastDeviceAddress = device.address
|
|
1548
|
+
connectGattLe(device)
|
|
1549
|
+
}
|
|
1550
|
+
|
|
1551
|
+
/**
|
|
1552
|
+
* Vendor connection model (from bitfantasy_glasses_sdk_transport_ble + their
|
|
1553
|
+
* flutter_blue_plus fork notes, "Android CTKD transport=BREDR"):
|
|
1554
|
+
*
|
|
1555
|
+
* 1. The main device is discovered over CLASSIC Bluetooth (it never advertises its
|
|
1556
|
+
* name over LE; the "<name>_BLE" peripheral is a separate ANCS-only device).
|
|
1557
|
+
* 2. Pairing is a CLASSIC (BR/EDR) bond. CTKD (cross-transport key derivation)
|
|
1558
|
+
* derives the LE keys — critically the IRK — during that bond.
|
|
1559
|
+
* 3. The DATA channel is GATT over LE (service 7033). The glasses advertise with a
|
|
1560
|
+
* resolvable private address, so connecting by their public MAC only works once
|
|
1561
|
+
* the phone holds the IRK from step 2. (Same reason iOS requires Settings-app
|
|
1562
|
+
* pairing first — iOS performs the classic bond + CTKD itself.)
|
|
1563
|
+
*/
|
|
1564
|
+
private fun connectGattLe(device: android.bluetooth.BluetoothDevice) {
|
|
1565
|
+
// Classic discovery degrades/aborts connections — always cancel first.
|
|
1566
|
+
try {
|
|
1567
|
+
bluetoothAdapter?.cancelDiscovery()
|
|
1568
|
+
} catch (_: Exception) {}
|
|
1569
|
+
|
|
1570
|
+
val bondState =
|
|
1571
|
+
try {
|
|
1572
|
+
device.bondState
|
|
1573
|
+
} catch (e: SecurityException) {
|
|
1574
|
+
android.bluetooth.BluetoothDevice.BOND_NONE
|
|
1575
|
+
}
|
|
1576
|
+
if (bondState != android.bluetooth.BluetoothDevice.BOND_BONDED) {
|
|
1577
|
+
// Bond over classic first (CTKD gives us the LE keys); connect on completion.
|
|
1578
|
+
startBond(device)
|
|
1579
|
+
return
|
|
1580
|
+
}
|
|
1581
|
+
connectLeTransport(device)
|
|
1582
|
+
}
|
|
1583
|
+
|
|
1584
|
+
/**
|
|
1585
|
+
* Kicks off (or retries) the classic bond. The glasses' BR/EDR bond is slow (30-45s)
|
|
1586
|
+
* and flaky — it can report BOND_NONE mid-pairing yet succeed on a retry — so this is
|
|
1587
|
+
* called both on the initial attempt and from the BOND_NONE handler.
|
|
1588
|
+
*/
|
|
1589
|
+
private fun startBond(device: android.bluetooth.BluetoothDevice) {
|
|
1590
|
+
val bondState =
|
|
1591
|
+
try {
|
|
1592
|
+
device.bondState
|
|
1593
|
+
} catch (e: SecurityException) {
|
|
1594
|
+
android.bluetooth.BluetoothDevice.BOND_NONE
|
|
1595
|
+
}
|
|
1596
|
+
// A previous (apparently failed) attempt may have bonded in the background.
|
|
1597
|
+
if (bondState == android.bluetooth.BluetoothDevice.BOND_BONDED) {
|
|
1598
|
+
Bridge.log("NIMO: already bonded with ${device.address} — connecting")
|
|
1599
|
+
unregisterBondReceiver()
|
|
1600
|
+
connectLeTransport(device)
|
|
1601
|
+
return
|
|
1602
|
+
}
|
|
1603
|
+
// Don't fire a second createBond on top of one already in flight (duplicate
|
|
1604
|
+
// connectById calls do this, and it poisons the bond).
|
|
1605
|
+
if (bondState == android.bluetooth.BluetoothDevice.BOND_BONDING ||
|
|
1606
|
+
(bondReceiver != null && bondingAddress == device.address)
|
|
1607
|
+
) {
|
|
1608
|
+
Bridge.log("NIMO: bond already in progress for ${device.address} — waiting")
|
|
1609
|
+
registerBondReceiver(device)
|
|
1610
|
+
bondingAddress = device.address
|
|
1611
|
+
return
|
|
1612
|
+
}
|
|
1613
|
+
bondAttempts++
|
|
1614
|
+
Bridge.log(
|
|
1615
|
+
"NIMO: creating classic bond with ${device.address} (attempt $bondAttempts/$MAX_BOND_ATTEMPTS)"
|
|
1616
|
+
)
|
|
1617
|
+
DeviceStore.apply("glasses", "connectionState", ConnTypes.BONDING)
|
|
1618
|
+
registerBondReceiver(device)
|
|
1619
|
+
bondingAddress = device.address
|
|
1620
|
+
val started =
|
|
1621
|
+
try {
|
|
1622
|
+
device.createBond()
|
|
1623
|
+
} catch (e: SecurityException) {
|
|
1624
|
+
Bridge.log("NIMO: createBond SecurityException: ${e.message}")
|
|
1625
|
+
false
|
|
1626
|
+
}
|
|
1627
|
+
if (!started) {
|
|
1628
|
+
Bridge.log("NIMO: createBond failed to start — trying LE connect anyway")
|
|
1629
|
+
unregisterBondReceiver()
|
|
1630
|
+
connectLeTransport(device)
|
|
1631
|
+
}
|
|
1632
|
+
}
|
|
1633
|
+
|
|
1634
|
+
private fun connectLeTransport(device: android.bluetooth.BluetoothDevice) {
|
|
1635
|
+
// GATT over BR/EDR: the glasses pair with a legacy (non-SC) link key, so no LE
|
|
1636
|
+
// keys exist (le_linkkey_known:F) and the controller records the device as
|
|
1637
|
+
// BR_EDR-only — the 7033 UART service is served over the classic link. This is
|
|
1638
|
+
// what the vendor's flutter_blue_plus fork patches in ("transport=BREDR"), and
|
|
1639
|
+
// why iOS needs Settings pairing (CoreBluetooth bridges GATT over BR/EDR only
|
|
1640
|
+
// for system-paired dual-mode accessories).
|
|
1641
|
+
Bridge.log("NIMO: connecting GATT over BR/EDR to ${device.address}")
|
|
1642
|
+
DeviceStore.apply("glasses", "connectionState", ConnTypes.CONNECTING)
|
|
1643
|
+
gatt =
|
|
1644
|
+
device.connectGatt(
|
|
1645
|
+
context,
|
|
1646
|
+
false,
|
|
1647
|
+
gattCallback,
|
|
1648
|
+
android.bluetooth.BluetoothDevice.TRANSPORT_BREDR
|
|
1649
|
+
)
|
|
1650
|
+
}
|
|
1651
|
+
|
|
1652
|
+
private var bondReceiver: android.content.BroadcastReceiver? = null
|
|
1653
|
+
private var bondAttempts = 0
|
|
1654
|
+
private var bondingAddress: String? = null
|
|
1655
|
+
|
|
1656
|
+
private fun registerBondReceiver(target: android.bluetooth.BluetoothDevice) {
|
|
1657
|
+
unregisterBondReceiver()
|
|
1658
|
+
val receiver =
|
|
1659
|
+
object : android.content.BroadcastReceiver() {
|
|
1660
|
+
@Suppress("deprecation")
|
|
1661
|
+
override fun onReceive(ctx: Context?, intent: android.content.Intent?) {
|
|
1662
|
+
if (intent?.action !=
|
|
1663
|
+
android.bluetooth.BluetoothDevice.ACTION_BOND_STATE_CHANGED
|
|
1664
|
+
)
|
|
1665
|
+
return
|
|
1666
|
+
val device: android.bluetooth.BluetoothDevice =
|
|
1667
|
+
intent.getParcelableExtra(
|
|
1668
|
+
android.bluetooth.BluetoothDevice.EXTRA_DEVICE
|
|
1669
|
+
)
|
|
1670
|
+
?: return
|
|
1671
|
+
val state =
|
|
1672
|
+
intent.getIntExtra(
|
|
1673
|
+
android.bluetooth.BluetoothDevice.EXTRA_BOND_STATE,
|
|
1674
|
+
android.bluetooth.BluetoothDevice.BOND_NONE
|
|
1675
|
+
)
|
|
1676
|
+
Bridge.log("NIMO: bond state changed: ${device.address} state=$state")
|
|
1677
|
+
if (device.address != target.address) return
|
|
1678
|
+
mainHandler.post {
|
|
1679
|
+
when (state) {
|
|
1680
|
+
android.bluetooth.BluetoothDevice.BOND_BONDED -> {
|
|
1681
|
+
Bridge.log(
|
|
1682
|
+
"NIMO: bonded with ${device.address} — settling before GATT connect"
|
|
1683
|
+
)
|
|
1684
|
+
unregisterBondReceiver()
|
|
1685
|
+
// Connecting GATT the instant BOND_BONDED fires races the
|
|
1686
|
+
// BR/EDR link teardown + CTKD finalization and comes back as
|
|
1687
|
+
// status 133. Let the stack settle so the first-ever connect
|
|
1688
|
+
// (right after pairing) succeeds instead of failing until the
|
|
1689
|
+
// user manually retries into the already-bonded fast path.
|
|
1690
|
+
mainHandler.postDelayed(
|
|
1691
|
+
{ connectLeTransport(device) },
|
|
1692
|
+
POST_BOND_SETTLE_MS
|
|
1693
|
+
)
|
|
1694
|
+
}
|
|
1695
|
+
android.bluetooth.BluetoothDevice.BOND_NONE -> {
|
|
1696
|
+
unregisterBondReceiver()
|
|
1697
|
+
// The bond can report NONE mid-pairing yet succeed on a
|
|
1698
|
+
// retry — only surface failure once retries are exhausted.
|
|
1699
|
+
if (bondAttempts < MAX_BOND_ATTEMPTS && !isDisconnecting) {
|
|
1700
|
+
Bridge.log(
|
|
1701
|
+
"NIMO: bond attempt $bondAttempts failed for ${device.address} — retrying in ${BOND_RETRY_DELAY_MS}ms"
|
|
1702
|
+
)
|
|
1703
|
+
mainHandler.postDelayed(
|
|
1704
|
+
{ if (!isDisconnecting) startBond(device) },
|
|
1705
|
+
BOND_RETRY_DELAY_MS
|
|
1706
|
+
)
|
|
1707
|
+
} else {
|
|
1708
|
+
Bridge.log(
|
|
1709
|
+
"NIMO: bonding failed with ${device.address} after $bondAttempts attempts"
|
|
1710
|
+
)
|
|
1711
|
+
Bridge.sendPairFailureEvent("errors:pairNeedDisconnect")
|
|
1712
|
+
}
|
|
1713
|
+
}
|
|
1714
|
+
// BOND_BONDING: in progress — wait.
|
|
1715
|
+
}
|
|
1716
|
+
}
|
|
1717
|
+
}
|
|
1718
|
+
}
|
|
1719
|
+
bondReceiver = receiver
|
|
1720
|
+
context.registerReceiver(
|
|
1721
|
+
receiver,
|
|
1722
|
+
android.content.IntentFilter(
|
|
1723
|
+
android.bluetooth.BluetoothDevice.ACTION_BOND_STATE_CHANGED
|
|
1724
|
+
)
|
|
1725
|
+
)
|
|
1726
|
+
}
|
|
1727
|
+
|
|
1728
|
+
private fun unregisterBondReceiver() {
|
|
1729
|
+
bondReceiver?.let {
|
|
1730
|
+
try {
|
|
1731
|
+
context.unregisterReceiver(it)
|
|
1732
|
+
} catch (_: Exception) {}
|
|
1733
|
+
}
|
|
1734
|
+
bondReceiver = null
|
|
1735
|
+
bondingAddress = null
|
|
1736
|
+
}
|
|
1737
|
+
|
|
1738
|
+
private fun connectByAddress(): Boolean {
|
|
1739
|
+
if (DEVICE_SEARCH_ID == "NOT_SET" || DEVICE_SEARCH_ID.isEmpty()) return false
|
|
1740
|
+
if (lastDeviceName != DEVICE_SEARCH_ID) return false
|
|
1741
|
+
val address = lastDeviceAddress ?: return false
|
|
1742
|
+
val adapter = bluetoothAdapter ?: return false
|
|
1743
|
+
return try {
|
|
1744
|
+
val device = adapter.getRemoteDevice(address)
|
|
1745
|
+
Bridge.log("NIMO: connectByAddress - ${device.name ?: address}")
|
|
1746
|
+
connectGattLe(device)
|
|
1747
|
+
true
|
|
1748
|
+
} catch (e: Exception) {
|
|
1749
|
+
Bridge.log("NIMO: connectByAddress failed: ${e.message}")
|
|
1750
|
+
false
|
|
1751
|
+
}
|
|
1752
|
+
}
|
|
1753
|
+
|
|
1754
|
+
private fun startPairingTimeout() {
|
|
1755
|
+
cancelPairingTimeout()
|
|
1756
|
+
val work = Runnable {
|
|
1757
|
+
if (!fullyBooted) {
|
|
1758
|
+
Bridge.log("NIMO: pairing timeout — handshake never completed")
|
|
1759
|
+
Bridge.sendPairFailureEvent("errors:pairNeedDisconnect")
|
|
1760
|
+
}
|
|
1761
|
+
}
|
|
1762
|
+
pairingTimeoutRunnable = work
|
|
1763
|
+
mainHandler.postDelayed(work, PAIRING_TIMEOUT_MS)
|
|
1764
|
+
}
|
|
1765
|
+
|
|
1766
|
+
private fun cancelPairingTimeout() {
|
|
1767
|
+
pairingTimeoutRunnable?.let { mainHandler.removeCallbacks(it) }
|
|
1768
|
+
pairingTimeoutRunnable = null
|
|
1769
|
+
}
|
|
1770
|
+
|
|
1771
|
+
// ---------- GATT Callback ----------
|
|
1772
|
+
|
|
1773
|
+
private val gattCallback =
|
|
1774
|
+
object : BluetoothGattCallback() {
|
|
1775
|
+
override fun onConnectionStateChange(g: BluetoothGatt, status: Int, newState: Int) {
|
|
1776
|
+
mainHandler.post {
|
|
1777
|
+
Bridge.log("NIMO: onConnectionStateChange status=$status newState=$newState")
|
|
1778
|
+
if (newState == BluetoothProfile.STATE_CONNECTED) {
|
|
1779
|
+
Bridge.log("NIMO: Connected to ${g.device?.name ?: "unknown"}")
|
|
1780
|
+
lastDeviceAddress = g.device?.address
|
|
1781
|
+
// Do NOT request a large MTU here: the vendor transport warns that
|
|
1782
|
+
// requesting one during connect makes some Nimo firmwares throw a
|
|
1783
|
+
// GATT error and loop reconnecting. The system negotiates the MTU;
|
|
1784
|
+
// frames larger than it go out as ATT long writes (WRITE_TYPE_DEFAULT).
|
|
1785
|
+
try {
|
|
1786
|
+
g.discoverServices()
|
|
1787
|
+
} catch (e: SecurityException) {
|
|
1788
|
+
Bridge.log("NIMO: discoverServices SecurityException: ${e.message}")
|
|
1789
|
+
}
|
|
1790
|
+
} else if (newState == BluetoothProfile.STATE_DISCONNECTED) {
|
|
1791
|
+
Bridge.log("NIMO: Disconnected")
|
|
1792
|
+
if (isDisconnecting) return@post
|
|
1793
|
+
|
|
1794
|
+
gatt?.close()
|
|
1795
|
+
gatt = null
|
|
1796
|
+
txChar = null
|
|
1797
|
+
rxChar = null
|
|
1798
|
+
micChar = null
|
|
1799
|
+
resetSessionState()
|
|
1800
|
+
|
|
1801
|
+
DeviceStore.apply("glasses", "connected", false)
|
|
1802
|
+
DeviceStore.apply("glasses", "fullyBooted", false)
|
|
1803
|
+
DeviceStore.apply("glasses", "connectionState", ConnTypes.DISCONNECTED)
|
|
1804
|
+
startReconnectionTimer()
|
|
1805
|
+
}
|
|
1806
|
+
}
|
|
1807
|
+
}
|
|
1808
|
+
|
|
1809
|
+
override fun onMtuChanged(g: BluetoothGatt, mtu: Int, status: Int) {
|
|
1810
|
+
Bridge.log("NIMO: onMtuChanged mtu=$mtu status=$status")
|
|
1811
|
+
mainHandler.post {
|
|
1812
|
+
negotiatedMtu = mtu
|
|
1813
|
+
try {
|
|
1814
|
+
g.discoverServices()
|
|
1815
|
+
} catch (e: SecurityException) {
|
|
1816
|
+
Bridge.log("NIMO: discoverServices SecurityException: ${e.message}")
|
|
1817
|
+
}
|
|
1818
|
+
}
|
|
1819
|
+
}
|
|
1820
|
+
|
|
1821
|
+
override fun onServicesDiscovered(g: BluetoothGatt, status: Int) {
|
|
1822
|
+
if (status != BluetoothGatt.GATT_SUCCESS) {
|
|
1823
|
+
Bridge.log("NIMO: onServicesDiscovered failed status=$status")
|
|
1824
|
+
return
|
|
1825
|
+
}
|
|
1826
|
+
mainHandler.post {
|
|
1827
|
+
val service = g.getService(NimoBLE.SERVICE_UUID)
|
|
1828
|
+
if (service == null) {
|
|
1829
|
+
// Dump everything so a UUID mismatch is diagnosable from logs.
|
|
1830
|
+
Bridge.log("NIMO: UART service not found; discovered services:")
|
|
1831
|
+
for (s in g.services ?: emptyList()) {
|
|
1832
|
+
val chars = s.characteristics.joinToString(", ") { it.uuid.toString() }
|
|
1833
|
+
Bridge.log("NIMO: service ${s.uuid} chars=[$chars]")
|
|
1834
|
+
}
|
|
1835
|
+
// Vendor treats a missing service as a connection FAILURE (it throws
|
|
1836
|
+
// and the upper layer reconnects). Disconnect so our reconnection /
|
|
1837
|
+
// transport-alternation logic engages instead of idling here.
|
|
1838
|
+
g.disconnect()
|
|
1839
|
+
return@post
|
|
1840
|
+
}
|
|
1841
|
+
txChar = service.getCharacteristic(NimoBLE.CHAR_TX)
|
|
1842
|
+
rxChar = service.getCharacteristic(NimoBLE.CHAR_RX)
|
|
1843
|
+
micChar = service.getCharacteristic(NimoBLE.CHAR_MIC)
|
|
1844
|
+
Bridge.log(
|
|
1845
|
+
"NIMO: chars tx=${txChar != null} rx=${rxChar != null} mic=${micChar != null}"
|
|
1846
|
+
)
|
|
1847
|
+
if (txChar == null || rxChar == null) {
|
|
1848
|
+
Bridge.log("NIMO: required characteristics missing")
|
|
1849
|
+
g.disconnect()
|
|
1850
|
+
return@post
|
|
1851
|
+
}
|
|
1852
|
+
enableNotifications(g, rxChar!!) {
|
|
1853
|
+
micChar?.let { mic -> enableNotifications(g, mic) {} }
|
|
1854
|
+
startHandshake()
|
|
1855
|
+
}
|
|
1856
|
+
}
|
|
1857
|
+
}
|
|
1858
|
+
|
|
1859
|
+
@Deprecated("Deprecated in API level 33")
|
|
1860
|
+
override fun onCharacteristicChanged(
|
|
1861
|
+
g: BluetoothGatt,
|
|
1862
|
+
characteristic: BluetoothGattCharacteristic
|
|
1863
|
+
) {
|
|
1864
|
+
val data = characteristic.value ?: return
|
|
1865
|
+
when (characteristic.uuid) {
|
|
1866
|
+
NimoBLE.CHAR_RX -> mainHandler.post { handleRxPacket(data) }
|
|
1867
|
+
NimoBLE.CHAR_MIC -> audioClient.enqueue(data)
|
|
1868
|
+
}
|
|
1869
|
+
}
|
|
1870
|
+
|
|
1871
|
+
override fun onCharacteristicWrite(
|
|
1872
|
+
g: BluetoothGatt,
|
|
1873
|
+
characteristic: BluetoothGattCharacteristic,
|
|
1874
|
+
status: Int
|
|
1875
|
+
) {
|
|
1876
|
+
mainHandler.postDelayed(
|
|
1877
|
+
{
|
|
1878
|
+
writeInFlight = false
|
|
1879
|
+
drainWriteQueue()
|
|
1880
|
+
},
|
|
1881
|
+
NimoBLE.INTER_FRAME_DELAY_MS
|
|
1882
|
+
)
|
|
1883
|
+
}
|
|
1884
|
+
|
|
1885
|
+
override fun onDescriptorWrite(
|
|
1886
|
+
g: BluetoothGatt,
|
|
1887
|
+
descriptor: BluetoothGattDescriptor,
|
|
1888
|
+
status: Int
|
|
1889
|
+
) {
|
|
1890
|
+
mainHandler.post { descriptorWriteCompletion?.let { it() } }
|
|
1891
|
+
}
|
|
1892
|
+
}
|
|
1893
|
+
|
|
1894
|
+
private var descriptorWriteCompletion: (() -> Unit)? = null
|
|
1895
|
+
|
|
1896
|
+
@Suppress("deprecation")
|
|
1897
|
+
private fun enableNotifications(
|
|
1898
|
+
g: BluetoothGatt,
|
|
1899
|
+
characteristic: BluetoothGattCharacteristic,
|
|
1900
|
+
onComplete: () -> Unit
|
|
1901
|
+
) {
|
|
1902
|
+
g.setCharacteristicNotification(characteristic, true)
|
|
1903
|
+
val descriptor = characteristic.getDescriptor(NimoBLE.CLIENT_CHARACTERISTIC_CONFIG)
|
|
1904
|
+
if (descriptor != null) {
|
|
1905
|
+
descriptorWriteCompletion = {
|
|
1906
|
+
descriptorWriteCompletion = null
|
|
1907
|
+
onComplete()
|
|
1908
|
+
}
|
|
1909
|
+
descriptor.value = BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE
|
|
1910
|
+
g.writeDescriptor(descriptor)
|
|
1911
|
+
} else {
|
|
1912
|
+
onComplete()
|
|
1913
|
+
}
|
|
1914
|
+
}
|
|
1915
|
+
|
|
1916
|
+
// ---------- Write Queue ----------
|
|
1917
|
+
|
|
1918
|
+
@Suppress("deprecation")
|
|
1919
|
+
private fun drainWriteQueue() {
|
|
1920
|
+
if (writeInFlight) return
|
|
1921
|
+
val item = writeQueue.removeFirstOrNull() ?: return
|
|
1922
|
+
val g = gatt
|
|
1923
|
+
if (g == null) {
|
|
1924
|
+
writeQueue.clear()
|
|
1925
|
+
return
|
|
1926
|
+
}
|
|
1927
|
+
writeInFlight = true
|
|
1928
|
+
item.char.writeType = BluetoothGattCharacteristic.WRITE_TYPE_DEFAULT
|
|
1929
|
+
item.char.value = item.bytes
|
|
1930
|
+
val ok =
|
|
1931
|
+
try {
|
|
1932
|
+
g.writeCharacteristic(item.char)
|
|
1933
|
+
} catch (e: Exception) {
|
|
1934
|
+
Bridge.log("NIMO: writeCharacteristic failed: ${e.message}")
|
|
1935
|
+
false
|
|
1936
|
+
}
|
|
1937
|
+
if (!ok) {
|
|
1938
|
+
// Another GATT op (e.g. a descriptor write) is in flight — requeue and retry
|
|
1939
|
+
// shortly instead of dropping the frame.
|
|
1940
|
+
writeInFlight = false
|
|
1941
|
+
writeQueue.addFirst(item)
|
|
1942
|
+
mainHandler.postDelayed({ drainWriteQueue() }, 100)
|
|
1943
|
+
return
|
|
1944
|
+
}
|
|
1945
|
+
// Watchdog: if the write callback never arrives, unblock the queue.
|
|
1946
|
+
writeWatchdog?.let { mainHandler.removeCallbacks(it) }
|
|
1947
|
+
val watchdog = Runnable {
|
|
1948
|
+
if (writeInFlight) {
|
|
1949
|
+
Bridge.log("NIMO: write watchdog fired — forcing queue drain")
|
|
1950
|
+
writeInFlight = false
|
|
1951
|
+
drainWriteQueue()
|
|
1952
|
+
}
|
|
1953
|
+
}
|
|
1954
|
+
writeWatchdog = watchdog
|
|
1955
|
+
mainHandler.postDelayed(watchdog, WRITE_WATCHDOG_MS)
|
|
1956
|
+
}
|
|
1957
|
+
|
|
1958
|
+
private fun enqueueWrite(char: BluetoothGattCharacteristic, bytes: ByteArray) {
|
|
1959
|
+
mainHandler.post {
|
|
1960
|
+
writeQueue.addLast(QueuedWrite(char, bytes))
|
|
1961
|
+
drainWriteQueue()
|
|
1962
|
+
}
|
|
1963
|
+
}
|
|
1964
|
+
|
|
1965
|
+
private fun sendFrame(frame: ByteArray) {
|
|
1966
|
+
val tx = txChar ?: return
|
|
1967
|
+
enqueueWrite(tx, frame)
|
|
1968
|
+
}
|
|
1969
|
+
|
|
1970
|
+
private fun enqueueFrames(frames: List<ByteArray>) {
|
|
1971
|
+
val tx = txChar ?: return
|
|
1972
|
+
mainHandler.post {
|
|
1973
|
+
for (frame in frames) {
|
|
1974
|
+
writeQueue.addLast(QueuedWrite(tx, frame))
|
|
1975
|
+
}
|
|
1976
|
+
drainWriteQueue()
|
|
1977
|
+
}
|
|
1978
|
+
}
|
|
1979
|
+
|
|
1980
|
+
// ---------- Pending ACKs ----------
|
|
1981
|
+
|
|
1982
|
+
private fun sendAwaitingAck(
|
|
1983
|
+
cmd: Int,
|
|
1984
|
+
key: Int,
|
|
1985
|
+
payload: ByteArray,
|
|
1986
|
+
timeoutMs: Long = ACK_TIMEOUT_MS,
|
|
1987
|
+
onResult: (Boolean) -> Unit
|
|
1988
|
+
) {
|
|
1989
|
+
val ackKey = (cmd shl 8) or key
|
|
1990
|
+
// Only one in-flight ack per (cmd,key); fail any previous waiter.
|
|
1991
|
+
pendingAcks.remove(ackKey)?.let {
|
|
1992
|
+
mainHandler.removeCallbacks(it.timeout)
|
|
1993
|
+
it.onResult(false)
|
|
1994
|
+
}
|
|
1995
|
+
val timeout = Runnable {
|
|
1996
|
+
pendingAcks.remove(ackKey)?.onResult(false)
|
|
1997
|
+
}
|
|
1998
|
+
pendingAcks[ackKey] = PendingAck(onResult, timeout)
|
|
1999
|
+
mainHandler.postDelayed(timeout, timeoutMs)
|
|
2000
|
+
sendFrame(NimoFrameCodec.encodeFrame(cmd, key, payload, needsAck = true))
|
|
2001
|
+
}
|
|
2002
|
+
|
|
2003
|
+
private fun resolvePendingAck(cmd: Int, key: Int, success: Boolean) {
|
|
2004
|
+
val ackKey = (cmd shl 8) or key
|
|
2005
|
+
pendingAcks.remove(ackKey)?.let {
|
|
2006
|
+
mainHandler.removeCallbacks(it.timeout)
|
|
2007
|
+
it.onResult(success)
|
|
2008
|
+
}
|
|
2009
|
+
}
|
|
2010
|
+
|
|
2011
|
+
private fun failAllPendingAcks() {
|
|
2012
|
+
val acks = pendingAcks.values.toList()
|
|
2013
|
+
pendingAcks.clear()
|
|
2014
|
+
for (ack in acks) {
|
|
2015
|
+
mainHandler.removeCallbacks(ack.timeout)
|
|
2016
|
+
ack.onResult(false)
|
|
2017
|
+
}
|
|
2018
|
+
}
|
|
2019
|
+
|
|
2020
|
+
// ---------- Handshake ----------
|
|
2021
|
+
|
|
2022
|
+
private fun startHandshake() {
|
|
2023
|
+
Bridge.log("NIMO: starting handshake (awaiting TWS service-connection state)")
|
|
2024
|
+
handshakeState = HandshakeState.AWAITING_TWS
|
|
2025
|
+
if (twsConnected) {
|
|
2026
|
+
proceedToTimeSync()
|
|
2027
|
+
return
|
|
2028
|
+
}
|
|
2029
|
+
cancelTwsTimeout()
|
|
2030
|
+
val timeout = Runnable {
|
|
2031
|
+
if (handshakeState == HandshakeState.AWAITING_TWS) {
|
|
2032
|
+
Bridge.log("NIMO: TWS state timeout — handshake failed, will reconnect")
|
|
2033
|
+
handshakeFailed()
|
|
2034
|
+
}
|
|
2035
|
+
}
|
|
2036
|
+
twsTimeoutRunnable = timeout
|
|
2037
|
+
mainHandler.postDelayed(timeout, TWS_TIMEOUT_MS)
|
|
2038
|
+
// Also actively query in case the glasses don't push the report unprompted.
|
|
2039
|
+
ping()
|
|
2040
|
+
}
|
|
2041
|
+
|
|
2042
|
+
private fun cancelTwsTimeout() {
|
|
2043
|
+
twsTimeoutRunnable?.let { mainHandler.removeCallbacks(it) }
|
|
2044
|
+
twsTimeoutRunnable = null
|
|
2045
|
+
}
|
|
2046
|
+
|
|
2047
|
+
private var setTimeAttempts = 0
|
|
2048
|
+
|
|
2049
|
+
private fun proceedToTimeSync() {
|
|
2050
|
+
if (handshakeState != HandshakeState.AWAITING_TWS) return
|
|
2051
|
+
cancelTwsTimeout()
|
|
2052
|
+
handshakeState = HandshakeState.AWAITING_TIME_ACK
|
|
2053
|
+
Bridge.log("NIMO: TWS OK — sending setTime (awaiting ACK)")
|
|
2054
|
+
setTimeAttempts = 0
|
|
2055
|
+
attemptSetTime()
|
|
2056
|
+
}
|
|
2057
|
+
|
|
2058
|
+
private fun attemptSetTime() {
|
|
2059
|
+
if (handshakeState != HandshakeState.AWAITING_TIME_ACK || gatt == null) return
|
|
2060
|
+
setTimeAttempts++
|
|
2061
|
+
sendAwaitingAck(
|
|
2062
|
+
NimoProtocol.CMD_SET_PARAMETER,
|
|
2063
|
+
NimoProtocol.SET_TIME,
|
|
2064
|
+
NimoFrameCodec.encodeDeviceTime()
|
|
2065
|
+
) { ok ->
|
|
2066
|
+
if (ok) {
|
|
2067
|
+
finishHandshake()
|
|
2068
|
+
} else if (setTimeAttempts < SET_TIME_MAX_ATTEMPTS &&
|
|
2069
|
+
handshakeState == HandshakeState.AWAITING_TIME_ACK &&
|
|
2070
|
+
gatt != null
|
|
2071
|
+
) {
|
|
2072
|
+
// The firmware can answer "busy" right after the link comes up —
|
|
2073
|
+
// give it a moment and retry instead of dropping the connection.
|
|
2074
|
+
Bridge.log("NIMO: setTime attempt $setTimeAttempts failed — retrying")
|
|
2075
|
+
mainHandler.postDelayed({ attemptSetTime() }, SET_TIME_RETRY_DELAY_MS)
|
|
2076
|
+
} else {
|
|
2077
|
+
Bridge.log("NIMO: setTime failed after $setTimeAttempts attempts — handshake failed")
|
|
2078
|
+
handshakeFailed()
|
|
2079
|
+
}
|
|
2080
|
+
}
|
|
2081
|
+
}
|
|
2082
|
+
|
|
2083
|
+
private fun finishHandshake() {
|
|
2084
|
+
Bridge.log("NIMO: handshake complete — fully connected")
|
|
2085
|
+
handshakeState = HandshakeState.READY
|
|
2086
|
+
sendFrame(
|
|
2087
|
+
NimoFrameCodec.encodeFrame(
|
|
2088
|
+
NimoProtocol.CMD_SET_PARAMETER,
|
|
2089
|
+
NimoProtocol.SET_PHONE_TYPE,
|
|
2090
|
+
byteArrayOf(NimoProtocol.PHONE_TYPE_OTHER.toByte()),
|
|
2091
|
+
needsAck = false
|
|
2092
|
+
)
|
|
2093
|
+
)
|
|
2094
|
+
getBatteryStatus()
|
|
2095
|
+
requestVersionInfo()
|
|
2096
|
+
|
|
2097
|
+
cancelPairingTimeout()
|
|
2098
|
+
reconnectionManager.stop()
|
|
2099
|
+
DeviceStore.apply("glasses", "connected", true)
|
|
2100
|
+
DeviceStore.apply("glasses", "fullyBooted", true)
|
|
2101
|
+
DeviceStore.apply("glasses", "connectionState", ConnTypes.CONNECTED)
|
|
2102
|
+
startTimers()
|
|
2103
|
+
}
|
|
2104
|
+
|
|
2105
|
+
private fun handshakeFailed() {
|
|
2106
|
+
handshakeState = HandshakeState.IDLE
|
|
2107
|
+
gatt?.disconnect()
|
|
2108
|
+
// The DISCONNECTED callback path handles cleanup + reconnection.
|
|
2109
|
+
}
|
|
2110
|
+
|
|
2111
|
+
private fun resetSessionState() {
|
|
2112
|
+
handshakeState = HandshakeState.IDLE
|
|
2113
|
+
twsConnected = false
|
|
2114
|
+
textAppEntered = false
|
|
2115
|
+
navAppEntered = false
|
|
2116
|
+
currentGlassesAppId = -1
|
|
2117
|
+
pendingText = null
|
|
2118
|
+
audioClient.stop()
|
|
2119
|
+
receiveAssembler.reset()
|
|
2120
|
+
writeQueue.clear()
|
|
2121
|
+
writeInFlight = false
|
|
2122
|
+
writeWatchdog?.let { mainHandler.removeCallbacks(it) }
|
|
2123
|
+
writeWatchdog = null
|
|
2124
|
+
failAllPendingAcks()
|
|
2125
|
+
stopTimers()
|
|
2126
|
+
}
|
|
2127
|
+
|
|
2128
|
+
private fun startReconnectionTimer() {
|
|
2129
|
+
reconnectionManager.start {
|
|
2130
|
+
if (fullyBooted) {
|
|
2131
|
+
return@start true
|
|
2132
|
+
}
|
|
2133
|
+
Bridge.log("NIMO: Attempting reconnection...")
|
|
2134
|
+
isDisconnecting = false
|
|
2135
|
+
// Each reconnection cycle is a fresh bond budget — the glasses drop their
|
|
2136
|
+
// bond on some disconnects (status 19), so this path may need to re-bond.
|
|
2137
|
+
bondAttempts = 0
|
|
2138
|
+
if (!connectByAddress()) {
|
|
2139
|
+
startScan()
|
|
2140
|
+
}
|
|
2141
|
+
return@start false
|
|
2142
|
+
}
|
|
2143
|
+
}
|
|
2144
|
+
|
|
2145
|
+
// ---------- Timers (battery poll keepalive + text queue) ----------
|
|
2146
|
+
|
|
2147
|
+
private fun startTimers() {
|
|
2148
|
+
stopTimers()
|
|
2149
|
+
val battery =
|
|
2150
|
+
object : Runnable {
|
|
2151
|
+
override fun run() {
|
|
2152
|
+
// Doubles as a keepalive so the link never goes idle long enough for
|
|
2153
|
+
// the stack to reclaim it.
|
|
2154
|
+
getBatteryStatus()
|
|
2155
|
+
mainHandler.postDelayed(this, BATTERY_POLL_MS)
|
|
2156
|
+
}
|
|
2157
|
+
}
|
|
2158
|
+
batteryPollRunnable = battery
|
|
2159
|
+
mainHandler.postDelayed(battery, BATTERY_POLL_MS)
|
|
2160
|
+
|
|
2161
|
+
val textQueue =
|
|
2162
|
+
object : Runnable {
|
|
2163
|
+
override fun run() {
|
|
2164
|
+
drainTextQueue()
|
|
2165
|
+
mainHandler.postDelayed(this, TEXT_QUEUE_TICK_MS)
|
|
2166
|
+
}
|
|
2167
|
+
}
|
|
2168
|
+
textQueueRunnable = textQueue
|
|
2169
|
+
mainHandler.postDelayed(textQueue, TEXT_QUEUE_TICK_MS)
|
|
2170
|
+
}
|
|
2171
|
+
|
|
2172
|
+
private fun stopTimers() {
|
|
2173
|
+
batteryPollRunnable?.let { mainHandler.removeCallbacks(it) }
|
|
2174
|
+
batteryPollRunnable = null
|
|
2175
|
+
textQueueRunnable?.let { mainHandler.removeCallbacks(it) }
|
|
2176
|
+
textQueueRunnable = null
|
|
2177
|
+
}
|
|
2178
|
+
|
|
2179
|
+
// ---------- Text Rendering ----------
|
|
2180
|
+
|
|
2181
|
+
/**
|
|
2182
|
+
* TEMP HW TEST: build a 160x160 grayscale test pattern (four vertical bands
|
|
2183
|
+
* at the 4 quantization levels: black | dark | light | white) and push it
|
|
2184
|
+
* through the exact same nav-minimap pipeline as displayBitmap. If the bands
|
|
2185
|
+
* render left→right on the glasses, the SDK bitmap path works end-to-end.
|
|
2186
|
+
*/
|
|
2187
|
+
private fun displaySampleBitmap() {
|
|
2188
|
+
val w = 160
|
|
2189
|
+
val h = 160
|
|
2190
|
+
val gray = ByteArray(w * h)
|
|
2191
|
+
for (y in 0 until h) {
|
|
2192
|
+
for (x in 0 until w) {
|
|
2193
|
+
val band = (x * 4 / w).coerceIn(0, 3) // 0..3 across the width
|
|
2194
|
+
gray[y * w + x] = (band * 85).toByte() // 0, 85, 170, 255
|
|
2195
|
+
}
|
|
2196
|
+
}
|
|
2197
|
+
val packed = packL8To2bpp(gray)
|
|
2198
|
+
val (payload, compression) = compressAdaptive(packed)
|
|
2199
|
+
if (!navAppEntered) {
|
|
2200
|
+
sendFrame(
|
|
2201
|
+
NimoFrameCodec.encodeFrame(
|
|
2202
|
+
NimoProtocol.CMD_CONTROL_INSTRUCTION,
|
|
2203
|
+
NimoProtocol.CTRL_ENTER_APP,
|
|
2204
|
+
byteArrayOf(
|
|
2205
|
+
NimoProtocol.APP_ID_NAV.toByte(),
|
|
2206
|
+
NimoProtocol.APP_MODE_STANDALONE.toByte()
|
|
2207
|
+
)
|
|
2208
|
+
)
|
|
2209
|
+
)
|
|
2210
|
+
navAppEntered = true
|
|
2211
|
+
}
|
|
2212
|
+
val content =
|
|
2213
|
+
NimoFrameCodec.imageHeader(
|
|
2214
|
+
width = w,
|
|
2215
|
+
height = h,
|
|
2216
|
+
formatBpp = NimoProtocol.FORMAT_2BPP,
|
|
2217
|
+
compression = compression,
|
|
2218
|
+
originalSize = packed.size,
|
|
2219
|
+
compressedSize =
|
|
2220
|
+
if (compression == NimoProtocol.COMPRESSION_NONE) 0
|
|
2221
|
+
else payload.size
|
|
2222
|
+
) + payload
|
|
2223
|
+
val frames =
|
|
2224
|
+
NimoFrameCodec.updateContentFrames(
|
|
2225
|
+
appId = NimoProtocol.APP_ID_NAV,
|
|
2226
|
+
layoutId = 0,
|
|
2227
|
+
resId = 0,
|
|
2228
|
+
resType = NimoProtocol.WIDGET_PICTURE,
|
|
2229
|
+
content = content
|
|
2230
|
+
)
|
|
2231
|
+
enqueueFrames(frames)
|
|
2232
|
+
Bridge.log(
|
|
2233
|
+
"NIMO: displaySampleBitmap → ${frames.size} frames, ${payload.size}B comp=$compression"
|
|
2234
|
+
)
|
|
2235
|
+
}
|
|
2236
|
+
|
|
2237
|
+
private fun drainTextQueue() {
|
|
2238
|
+
val text = pendingText ?: return
|
|
2239
|
+
if (handshakeState != HandshakeState.READY) return
|
|
2240
|
+
pendingText = null
|
|
2241
|
+
|
|
2242
|
+
// All text (text_wall / double_text_wall / reference_card / positioned_text) renders on
|
|
2243
|
+
// the ASR note page — the only text surface confirmed to render on hardware.
|
|
2244
|
+
Bridge.log("NIMO: text → glasses (enterApp=${!textAppEntered}): \"${text.take(60)}\"")
|
|
2245
|
+
if (!textAppEntered) {
|
|
2246
|
+
sendFrame(
|
|
2247
|
+
NimoFrameCodec.encodeFrame(
|
|
2248
|
+
NimoProtocol.CMD_CONTROL_INSTRUCTION,
|
|
2249
|
+
NimoProtocol.CTRL_ENTER_APP,
|
|
2250
|
+
byteArrayOf(textAppId.toByte(), NimoProtocol.APP_MODE_STANDALONE.toByte())
|
|
2251
|
+
)
|
|
2252
|
+
)
|
|
2253
|
+
// Optimistic; corrected by app-state reports if the glasses refuse/exit.
|
|
2254
|
+
textAppEntered = true
|
|
2255
|
+
}
|
|
2256
|
+
|
|
2257
|
+
val frames =
|
|
2258
|
+
NimoFrameCodec.updateContentFrames(
|
|
2259
|
+
appId = textAppId,
|
|
2260
|
+
layoutId = textLayoutId,
|
|
2261
|
+
resId = textResId,
|
|
2262
|
+
resType = NimoProtocol.WIDGET_TEXT_NEW,
|
|
2263
|
+
content = text.toByteArray(Charsets.UTF_8)
|
|
2264
|
+
)
|
|
2265
|
+
enqueueFrames(frames)
|
|
2266
|
+
}
|
|
2267
|
+
|
|
2268
|
+
// ---------- Incoming Data ----------
|
|
2269
|
+
|
|
2270
|
+
private fun handleRxPacket(packet: ByteArray) {
|
|
2271
|
+
receiveAssembler.cleanup()
|
|
2272
|
+
for (frame in receiveAssembler.ingest(packet)) {
|
|
2273
|
+
val decoded = NimoFrameCodec.decode(frame) ?: continue
|
|
2274
|
+
val cmd = decoded.cmd ?: continue
|
|
2275
|
+
val key = decoded.key ?: continue
|
|
2276
|
+
if (cmd == NimoProtocol.CMD_INSTRUCTION_REPORT) {
|
|
2277
|
+
handleReport(key, decoded.data ?: ByteArray(0))
|
|
2278
|
+
} else {
|
|
2279
|
+
handleResponse(cmd, key, decoded.statusCode ?: 1, decoded.data ?: ByteArray(0))
|
|
2280
|
+
}
|
|
2281
|
+
}
|
|
2282
|
+
}
|
|
2283
|
+
|
|
2284
|
+
private fun handleReport(key: Int, data: ByteArray) {
|
|
2285
|
+
when (key) {
|
|
2286
|
+
NimoProtocol.REPORT_INPUT -> {
|
|
2287
|
+
if (data.isNotEmpty()) handleInputEvent(data[0].toInt() and 0xFF)
|
|
2288
|
+
}
|
|
2289
|
+
NimoProtocol.REPORT_APP -> {
|
|
2290
|
+
if (data.size >= 2) {
|
|
2291
|
+
val appId = data[0].toInt() and 0xFF
|
|
2292
|
+
val phase = data[1].toInt() and 0xFF
|
|
2293
|
+
Bridge.log("NIMO: app state report appId=$appId phase=$phase")
|
|
2294
|
+
when (phase) {
|
|
2295
|
+
NimoProtocol.STATE_ENTER -> {
|
|
2296
|
+
currentGlassesAppId = appId
|
|
2297
|
+
if (appId != textAppId) textAppEntered = false
|
|
2298
|
+
if (appId != NimoProtocol.APP_ID_NAV) navAppEntered = false
|
|
2299
|
+
}
|
|
2300
|
+
NimoProtocol.STATE_EXIT -> {
|
|
2301
|
+
if (appId == currentGlassesAppId) currentGlassesAppId = -1
|
|
2302
|
+
if (appId == textAppId) textAppEntered = false
|
|
2303
|
+
if (appId == NimoProtocol.APP_ID_NAV) navAppEntered = false
|
|
2304
|
+
}
|
|
2305
|
+
}
|
|
2306
|
+
}
|
|
2307
|
+
}
|
|
2308
|
+
NimoProtocol.REPORT_TWS -> {
|
|
2309
|
+
if (data.isNotEmpty()) {
|
|
2310
|
+
onTwsState((data[0].toInt() and 0xFF) >= 1)
|
|
2311
|
+
}
|
|
2312
|
+
}
|
|
2313
|
+
NimoProtocol.REPORT_BUSINESS -> handleBusinessReport(data)
|
|
2314
|
+
NimoProtocol.REPORT_GATT_STATE -> {}
|
|
2315
|
+
else -> Bridge.log("NIMO: unknown report key=$key")
|
|
2316
|
+
}
|
|
2317
|
+
}
|
|
2318
|
+
|
|
2319
|
+
private fun handleBusinessReport(data: ByteArray) {
|
|
2320
|
+
if (data.isEmpty()) return
|
|
2321
|
+
val id = data[0].toInt() and 0xFF
|
|
2322
|
+
val v = data.copyOfRange(1, data.size)
|
|
2323
|
+
when (id) {
|
|
2324
|
+
NimoProtocol.BUSINESS_HEARTBEAT -> {
|
|
2325
|
+
// [leftMv(2)][rightMv(2)][btSysStatus(4)][twsStatus(1)][slaveGatt(1)]
|
|
2326
|
+
if (v.size >= 10) {
|
|
2327
|
+
onTwsState((v[8].toInt() and 0xFF) >= 1)
|
|
2328
|
+
}
|
|
2329
|
+
}
|
|
2330
|
+
NimoProtocol.BUSINESS_BATTERY -> {
|
|
2331
|
+
if (v.size >= 4) {
|
|
2332
|
+
applyBattery(
|
|
2333
|
+
left = v[0].toInt() and 0xFF,
|
|
2334
|
+
right = v[1].toInt() and 0xFF,
|
|
2335
|
+
leftCharging = v[2].toInt() == 1,
|
|
2336
|
+
rightCharging = v[3].toInt() == 1
|
|
2337
|
+
)
|
|
2338
|
+
}
|
|
2339
|
+
}
|
|
2340
|
+
else -> {}
|
|
2341
|
+
}
|
|
2342
|
+
}
|
|
2343
|
+
|
|
2344
|
+
private fun onTwsState(connected: Boolean) {
|
|
2345
|
+
twsConnected = connected
|
|
2346
|
+
if (connected && handshakeState == HandshakeState.AWAITING_TWS) {
|
|
2347
|
+
proceedToTimeSync()
|
|
2348
|
+
}
|
|
2349
|
+
if (!connected && handshakeState == HandshakeState.READY) {
|
|
2350
|
+
Bridge.log("NIMO: TWS service dropped mid-session (arm removed/off?)")
|
|
2351
|
+
}
|
|
2352
|
+
}
|
|
2353
|
+
|
|
2354
|
+
private fun handleInputEvent(code: Int) {
|
|
2355
|
+
val timestamp = System.currentTimeMillis()
|
|
2356
|
+
when (code) {
|
|
2357
|
+
NimoProtocol.INPUT_HEAD_UP -> {
|
|
2358
|
+
DeviceStore.apply("glasses", "headUp", true)
|
|
2359
|
+
Bridge.sendHeadUp(true)
|
|
2360
|
+
}
|
|
2361
|
+
NimoProtocol.INPUT_HEAD_DOWN -> {
|
|
2362
|
+
DeviceStore.apply("glasses", "headUp", false)
|
|
2363
|
+
Bridge.sendHeadUp(false)
|
|
2364
|
+
}
|
|
2365
|
+
NimoProtocol.INPUT_CLICK_RIGHT, NimoProtocol.INPUT_CLICK_LEFT ->
|
|
2366
|
+
Bridge.sendTouchEvent(DeviceTypes.NIMO, "single_tap", timestamp)
|
|
2367
|
+
NimoProtocol.INPUT_DOUBLE_CLICK_RIGHT, NimoProtocol.INPUT_DOUBLE_CLICK_LEFT ->
|
|
2368
|
+
Bridge.sendTouchEvent(DeviceTypes.NIMO, "double_tap", timestamp)
|
|
2369
|
+
NimoProtocol.INPUT_LONG_PRESS_RIGHT, NimoProtocol.INPUT_LONG_PRESS_LEFT ->
|
|
2370
|
+
Bridge.sendTouchEvent(DeviceTypes.NIMO, "long_press", timestamp)
|
|
2371
|
+
NimoProtocol.INPUT_TOUCH_PRESS_RIGHT,
|
|
2372
|
+
NimoProtocol.INPUT_TOUCH_RELEASE_RIGHT,
|
|
2373
|
+
NimoProtocol.INPUT_TOUCH_PRESS_LEFT,
|
|
2374
|
+
NimoProtocol.INPUT_TOUCH_RELEASE_LEFT -> {
|
|
2375
|
+
// Raw press/release transitions are too chatty to forward; taps cover the UX.
|
|
2376
|
+
}
|
|
2377
|
+
else -> Bridge.log("NIMO: unknown input event code=$code")
|
|
2378
|
+
}
|
|
2379
|
+
}
|
|
2380
|
+
|
|
2381
|
+
private fun handleResponse(cmd: Int, key: Int, statusCode: Int, data: ByteArray) {
|
|
2382
|
+
if (statusCode != 0) {
|
|
2383
|
+
// Protocol error codes: 1 fail, 2 timeout, 3 invalid format, 4 no memory,
|
|
2384
|
+
// 5 not supported, 6 bad parameter, 7 device busy.
|
|
2385
|
+
Bridge.log("NIMO: response cmd=$cmd key=$key error status=$statusCode")
|
|
2386
|
+
}
|
|
2387
|
+
resolvePendingAck(cmd, key, statusCode == 0)
|
|
2388
|
+
|
|
2389
|
+
if (cmd != NimoProtocol.CMD_GET_PARAMETER || statusCode != 0) return
|
|
2390
|
+
when (key) {
|
|
2391
|
+
NimoProtocol.GET_BATTERY -> {
|
|
2392
|
+
if (data.size >= 4) {
|
|
2393
|
+
applyBattery(
|
|
2394
|
+
left = data[0].toInt() and 0xFF,
|
|
2395
|
+
right = data[1].toInt() and 0xFF,
|
|
2396
|
+
leftCharging = data[2].toInt() == 1,
|
|
2397
|
+
rightCharging = data[3].toInt() == 1
|
|
2398
|
+
)
|
|
2399
|
+
}
|
|
2400
|
+
}
|
|
2401
|
+
NimoProtocol.GET_VERSION -> {
|
|
2402
|
+
if (data.size >= 4) {
|
|
2403
|
+
val v =
|
|
2404
|
+
(data[0].toInt() and 0xFF) or
|
|
2405
|
+
((data[1].toInt() and 0xFF) shl 8) or
|
|
2406
|
+
((data[2].toInt() and 0xFF) shl 16) or
|
|
2407
|
+
((data[3].toInt() and 0xFF) shl 24)
|
|
2408
|
+
val major = (v ushr 28) and 0xF
|
|
2409
|
+
val minor = (v ushr 21) and 0x7F
|
|
2410
|
+
val micro = (v ushr 12) and 0x1FF
|
|
2411
|
+
val build = v and 0xFFF
|
|
2412
|
+
firmwareVersionPacked = "$major.$minor.$micro.$build"
|
|
2413
|
+
emitVersionInfo()
|
|
2414
|
+
}
|
|
2415
|
+
}
|
|
2416
|
+
NimoProtocol.GET_VERSION_DETAIL -> {
|
|
2417
|
+
var end = data.size
|
|
2418
|
+
while (end > 0 && data[end - 1].toInt() == 0) end--
|
|
2419
|
+
firmwareVersionDetail = String(data, 0, end, Charsets.UTF_8).trim()
|
|
2420
|
+
emitVersionInfo()
|
|
2421
|
+
}
|
|
2422
|
+
NimoProtocol.GET_TWS_STATUS -> {
|
|
2423
|
+
if (data.isNotEmpty()) onTwsState((data[0].toInt() and 0xFF) >= 1)
|
|
2424
|
+
}
|
|
2425
|
+
else -> {}
|
|
2426
|
+
}
|
|
2427
|
+
}
|
|
2428
|
+
|
|
2429
|
+
private fun applyBattery(left: Int, right: Int, leftCharging: Boolean, rightCharging: Boolean) {
|
|
2430
|
+
// Two independent arms → report the conservative (lower) level.
|
|
2431
|
+
val level = minOf(left, right)
|
|
2432
|
+
val charging = leftCharging || rightCharging
|
|
2433
|
+
if (level != lastBatteryLevel || charging != lastCharging) {
|
|
2434
|
+
lastBatteryLevel = level
|
|
2435
|
+
lastCharging = charging
|
|
2436
|
+
DeviceStore.apply("glasses", "batteryLevel", level)
|
|
2437
|
+
DeviceStore.apply("glasses", "charging", charging)
|
|
2438
|
+
Bridge.sendBatteryStatus(level, charging)
|
|
2439
|
+
}
|
|
2440
|
+
}
|
|
2441
|
+
|
|
2442
|
+
private fun emitVersionInfo() {
|
|
2443
|
+
val version = firmwareVersionDetail.ifEmpty { firmwareVersionPacked }
|
|
2444
|
+
if (version.isEmpty()) return
|
|
2445
|
+
DeviceStore.apply("glasses", "firmwareVersion", version)
|
|
2446
|
+
Bridge.sendVersionInfo(mapOf("firmwareVersion" to version))
|
|
2447
|
+
}
|
|
2448
|
+
|
|
2449
|
+
// ---------- Bitmap Helpers ----------
|
|
2450
|
+
|
|
2451
|
+
/** Aspect-fit the bitmap into [width]x[height] on black, then convert to L8 grayscale. */
|
|
2452
|
+
private fun bitmapToGrayscale(source: Bitmap, width: Int, height: Int): ByteArray {
|
|
2453
|
+
val scaled = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888)
|
|
2454
|
+
val canvas = Canvas(scaled)
|
|
2455
|
+
canvas.drawColor(Color.BLACK)
|
|
2456
|
+
val scale = minOf(width.toFloat() / source.width, height.toFloat() / source.height)
|
|
2457
|
+
val dw = (source.width * scale).toInt()
|
|
2458
|
+
val dh = (source.height * scale).toInt()
|
|
2459
|
+
val left = (width - dw) / 2
|
|
2460
|
+
val top = (height - dh) / 2
|
|
2461
|
+
canvas.drawBitmap(
|
|
2462
|
+
source,
|
|
2463
|
+
null,
|
|
2464
|
+
Rect(left, top, left + dw, top + dh),
|
|
2465
|
+
Paint(Paint.FILTER_BITMAP_FLAG)
|
|
2466
|
+
)
|
|
2467
|
+
|
|
2468
|
+
val pixels = IntArray(width * height)
|
|
2469
|
+
scaled.getPixels(pixels, 0, width, 0, 0, width, height)
|
|
2470
|
+
val gray = ByteArray(width * height)
|
|
2471
|
+
for (i in pixels.indices) {
|
|
2472
|
+
val p = pixels[i]
|
|
2473
|
+
val luminance =
|
|
2474
|
+
(0.299 * Color.red(p) + 0.587 * Color.green(p) + 0.114 * Color.blue(p)).toInt()
|
|
2475
|
+
// Invert luminance. MentraOS app bitmaps follow the platform convention of dark
|
|
2476
|
+
// content on a light background (see BitmapJavaUtils: gray<128 -> "black"). Nimo's
|
|
2477
|
+
// additive display lights up high 2bpp values (3 = white), so sending luma as-is
|
|
2478
|
+
// lights the whole lens for the background and leaves the content dark — the
|
|
2479
|
+
// inverted look. 255 - luma flips it so content renders lit on a dark lens.
|
|
2480
|
+
gray[i] = (255 - luminance.coerceIn(0, 255)).toByte()
|
|
2481
|
+
}
|
|
2482
|
+
scaled.recycle()
|
|
2483
|
+
return gray
|
|
2484
|
+
}
|
|
2485
|
+
|
|
2486
|
+
/** L8 → 2bpp: thresholds 0x40/0x80/0xC0, 4 pixels per byte, MSB first. */
|
|
2487
|
+
private fun packL8To2bpp(l8: ByteArray): ByteArray {
|
|
2488
|
+
val outBytes = (l8.size + 3) shr 2
|
|
2489
|
+
val dst = ByteArray(outBytes)
|
|
2490
|
+
for (i in 0 until outBytes) {
|
|
2491
|
+
var packed = 0
|
|
2492
|
+
for (j in 0 until 4) {
|
|
2493
|
+
val idx = i * 4 + j
|
|
2494
|
+
var value = 0
|
|
2495
|
+
if (idx < l8.size) {
|
|
2496
|
+
val px = l8[idx].toInt() and 0xFF
|
|
2497
|
+
value =
|
|
2498
|
+
when {
|
|
2499
|
+
px < 0x40 -> 0
|
|
2500
|
+
px < 0x80 -> 1
|
|
2501
|
+
px < 0xC0 -> 2
|
|
2502
|
+
else -> 3
|
|
2503
|
+
}
|
|
2504
|
+
}
|
|
2505
|
+
packed = packed or ((value and 0x03) shl (6 - 2 * j))
|
|
2506
|
+
}
|
|
2507
|
+
dst[i] = packed.toByte()
|
|
2508
|
+
}
|
|
2509
|
+
return dst
|
|
2510
|
+
}
|
|
2511
|
+
|
|
2512
|
+
/** zlib-compress; falls back to uncompressed when that isn't smaller. */
|
|
2513
|
+
private fun compressAdaptive(data: ByteArray): Pair<ByteArray, Int> {
|
|
2514
|
+
if (data.isEmpty()) return Pair(data, NimoProtocol.COMPRESSION_NONE)
|
|
2515
|
+
val deflater = Deflater()
|
|
2516
|
+
deflater.setInput(data)
|
|
2517
|
+
deflater.finish()
|
|
2518
|
+
val out = ByteArrayOutputStream(data.size)
|
|
2519
|
+
val buffer = ByteArray(4096)
|
|
2520
|
+
while (!deflater.finished()) {
|
|
2521
|
+
val n = deflater.deflate(buffer)
|
|
2522
|
+
out.write(buffer, 0, n)
|
|
2523
|
+
}
|
|
2524
|
+
deflater.end()
|
|
2525
|
+
val compressed = out.toByteArray()
|
|
2526
|
+
return if (compressed.size < data.size) {
|
|
2527
|
+
Pair(compressed, NimoProtocol.COMPRESSION_ZLIB)
|
|
2528
|
+
} else {
|
|
2529
|
+
Pair(data, NimoProtocol.COMPRESSION_NONE)
|
|
2530
|
+
}
|
|
2531
|
+
}
|
|
2532
|
+
}
|