@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
|
@@ -28,13 +28,16 @@ import java.io.ByteArrayOutputStream
|
|
|
28
28
|
import java.util.TimeZone
|
|
29
29
|
import java.util.UUID
|
|
30
30
|
import java.util.regex.Pattern
|
|
31
|
+
import kotlinx.coroutines.CompletableDeferred
|
|
31
32
|
import kotlinx.coroutines.CoroutineScope
|
|
32
33
|
import kotlinx.coroutines.Dispatchers
|
|
34
|
+
import kotlinx.coroutines.Job
|
|
33
35
|
import kotlinx.coroutines.SupervisorJob
|
|
36
|
+
import kotlinx.coroutines.channels.Channel
|
|
34
37
|
import kotlinx.coroutines.delay
|
|
38
|
+
import kotlinx.coroutines.isActive
|
|
35
39
|
import kotlinx.coroutines.launch
|
|
36
|
-
import kotlinx.coroutines.
|
|
37
|
-
import kotlinx.coroutines.sync.withLock
|
|
40
|
+
import kotlinx.coroutines.withTimeoutOrNull
|
|
38
41
|
|
|
39
42
|
// ---------- G2 Protocol Constants ----------
|
|
40
43
|
|
|
@@ -264,10 +267,12 @@ private class ProtobufReader(private val data: ByteArray) {
|
|
|
264
267
|
val v = readVarint()
|
|
265
268
|
if (v != null) fields[fieldNum] = v.toInt()
|
|
266
269
|
}
|
|
270
|
+
|
|
267
271
|
2 -> {
|
|
268
272
|
val d = readBytes()
|
|
269
273
|
if (d != null) fields[fieldNum] = d
|
|
270
274
|
}
|
|
275
|
+
|
|
271
276
|
else -> skipField(wireType)
|
|
272
277
|
}
|
|
273
278
|
}
|
|
@@ -279,18 +284,18 @@ private class ProtobufReader(private val data: ByteArray) {
|
|
|
279
284
|
|
|
280
285
|
private object EvenHubProto {
|
|
281
286
|
fun textContainerProperty(
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
287
|
+
x: Int,
|
|
288
|
+
y: Int,
|
|
289
|
+
width: Int,
|
|
290
|
+
height: Int,
|
|
291
|
+
borderWidth: Int = 0,
|
|
292
|
+
borderColor: Int = 0,
|
|
293
|
+
borderRadius: Int = 0,
|
|
294
|
+
paddingLength: Int = 0,
|
|
295
|
+
containerID: Int,
|
|
296
|
+
containerName: String? = null,
|
|
297
|
+
isEventCapture: Boolean = false,
|
|
298
|
+
content: String? = null
|
|
294
299
|
): ByteArray {
|
|
295
300
|
val w = ProtobufWriter()
|
|
296
301
|
w.writeInt32Field(1, x)
|
|
@@ -309,12 +314,12 @@ private object EvenHubProto {
|
|
|
309
314
|
}
|
|
310
315
|
|
|
311
316
|
fun imageContainerProperty(
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
317
|
+
x: Int,
|
|
318
|
+
y: Int,
|
|
319
|
+
width: Int,
|
|
320
|
+
height: Int,
|
|
321
|
+
containerID: Int,
|
|
322
|
+
containerName: String? = null
|
|
318
323
|
): ByteArray {
|
|
319
324
|
val w = ProtobufWriter()
|
|
320
325
|
w.writeInt32Field(1, x)
|
|
@@ -327,14 +332,14 @@ private object EvenHubProto {
|
|
|
327
332
|
}
|
|
328
333
|
|
|
329
334
|
fun imageRawDataUpdate(
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
335
|
+
containerID: Int,
|
|
336
|
+
containerName: String? = null,
|
|
337
|
+
mapSessionId: Int,
|
|
338
|
+
mapTotalSize: Int,
|
|
339
|
+
compressMode: Int = 0,
|
|
340
|
+
mapFragmentIndex: Int,
|
|
341
|
+
mapFragmentPacketSize: Int,
|
|
342
|
+
mapRawData: ByteArray
|
|
338
343
|
): ByteArray {
|
|
339
344
|
val w = ProtobufWriter()
|
|
340
345
|
w.writeInt32Field(1, containerID)
|
|
@@ -349,9 +354,9 @@ private object EvenHubProto {
|
|
|
349
354
|
}
|
|
350
355
|
|
|
351
356
|
fun createStartupPageContainer(
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
357
|
+
containerTotalNum: Int,
|
|
358
|
+
textContainers: List<ByteArray> = emptyList(),
|
|
359
|
+
imageContainers: List<ByteArray> = emptyList()
|
|
355
360
|
): ByteArray {
|
|
356
361
|
val w = ProtobufWriter()
|
|
357
362
|
w.writeInt32Field(1, containerTotalNum)
|
|
@@ -361,10 +366,10 @@ private object EvenHubProto {
|
|
|
361
366
|
}
|
|
362
367
|
|
|
363
368
|
fun textContainerUpgrade(
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
369
|
+
containerID: Int,
|
|
370
|
+
contentOffset: Int = 0,
|
|
371
|
+
contentLength: Int,
|
|
372
|
+
content: String
|
|
368
373
|
): ByteArray {
|
|
369
374
|
val w = ProtobufWriter()
|
|
370
375
|
w.writeInt32Field(1, containerID)
|
|
@@ -393,11 +398,11 @@ private object EvenHubProto {
|
|
|
393
398
|
}
|
|
394
399
|
|
|
395
400
|
fun evenHubMessage(
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
+
cmd: EvenHubCmd,
|
|
402
|
+
subFieldNumber: Int,
|
|
403
|
+
subMessage: ByteArray,
|
|
404
|
+
magicRandom: Int = 0,
|
|
405
|
+
appId: Int? = null
|
|
401
406
|
): ByteArray {
|
|
402
407
|
val w = ProtobufWriter()
|
|
403
408
|
w.writeInt32Field(1, cmd.value) // Cmd (field 1, enum)
|
|
@@ -408,68 +413,68 @@ private object EvenHubProto {
|
|
|
408
413
|
}
|
|
409
414
|
|
|
410
415
|
fun createPageMessage(
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
416
|
+
textContainers: List<ByteArray> = emptyList(),
|
|
417
|
+
imageContainers: List<ByteArray> = emptyList(),
|
|
418
|
+
magicRandom: Int = 0,
|
|
419
|
+
appId: Int? = null
|
|
415
420
|
): ByteArray {
|
|
416
421
|
val total = textContainers.size + imageContainers.size
|
|
417
422
|
val createMsg = createStartupPageContainer(total, textContainers, imageContainers)
|
|
418
423
|
return evenHubMessage(
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
+
EvenHubCmd.CREATE_STARTUP_PAGE,
|
|
425
|
+
3,
|
|
426
|
+
createMsg,
|
|
427
|
+
magicRandom = magicRandom,
|
|
428
|
+
appId = null
|
|
424
429
|
)
|
|
425
430
|
}
|
|
426
431
|
|
|
427
432
|
fun rebuildPageMessage(
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
433
|
+
textContainers: List<ByteArray> = emptyList(),
|
|
434
|
+
imageContainers: List<ByteArray> = emptyList(),
|
|
435
|
+
magicRandom: Int = 0,
|
|
436
|
+
appId: Int? = null
|
|
432
437
|
): ByteArray {
|
|
433
438
|
val total = textContainers.size + imageContainers.size
|
|
434
439
|
val rebuildMsg = createStartupPageContainer(total, textContainers, imageContainers)
|
|
435
440
|
return evenHubMessage(
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
+
EvenHubCmd.REBUILD_PAGE,
|
|
442
|
+
7,
|
|
443
|
+
rebuildMsg,
|
|
444
|
+
magicRandom = magicRandom,
|
|
445
|
+
appId = appId
|
|
441
446
|
)
|
|
442
447
|
}
|
|
443
448
|
|
|
444
449
|
fun updateImageRawDataMessage(
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
450
|
+
containerID: Int,
|
|
451
|
+
containerName: String? = null,
|
|
452
|
+
mapSessionId: Int,
|
|
453
|
+
mapTotalSize: Int,
|
|
454
|
+
compressMode: Int = 0,
|
|
455
|
+
mapFragmentIndex: Int,
|
|
456
|
+
mapFragmentPacketSize: Int,
|
|
457
|
+
mapRawData: ByteArray
|
|
453
458
|
): ByteArray {
|
|
454
459
|
val updateMsg =
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
460
|
+
imageRawDataUpdate(
|
|
461
|
+
containerID,
|
|
462
|
+
containerName,
|
|
463
|
+
mapSessionId,
|
|
464
|
+
mapTotalSize,
|
|
465
|
+
compressMode,
|
|
466
|
+
mapFragmentIndex,
|
|
467
|
+
mapFragmentPacketSize,
|
|
468
|
+
mapRawData
|
|
469
|
+
)
|
|
465
470
|
return evenHubMessage(EvenHubCmd.UPDATE_IMAGE_RAW_DATA, 5, updateMsg)
|
|
466
471
|
}
|
|
467
472
|
|
|
468
473
|
fun updateTextMessage(
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
474
|
+
containerID: Int,
|
|
475
|
+
contentOffset: Int = 0,
|
|
476
|
+
contentLength: Int,
|
|
477
|
+
content: String
|
|
473
478
|
): ByteArray {
|
|
474
479
|
val upgradeMsg = textContainerUpgrade(containerID, contentOffset, contentLength, content)
|
|
475
480
|
return evenHubMessage(EvenHubCmd.UPDATE_TEXT_DATA, 9, upgradeMsg)
|
|
@@ -524,9 +529,9 @@ private object EvenHubProto {
|
|
|
524
529
|
* ImuReportPace pacing code; ignored when disabling.
|
|
525
530
|
*/
|
|
526
531
|
fun imuControlMessage(
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
532
|
+
enable: Boolean,
|
|
533
|
+
reportFrq: Int = IMU_PACE_P100,
|
|
534
|
+
magicRandom: Int = 0
|
|
530
535
|
): ByteArray {
|
|
531
536
|
val imuMsg = imuCtrlCmd(enable, reportFrq)
|
|
532
537
|
val w = ProtobufWriter()
|
|
@@ -596,15 +601,15 @@ private object DevSettingsProto {
|
|
|
596
601
|
}
|
|
597
602
|
|
|
598
603
|
fun ringConnectInfo(
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
604
|
+
magicRandom: Int,
|
|
605
|
+
connect: Boolean,
|
|
606
|
+
ringMac: ByteArray,
|
|
607
|
+
ringName: String = ""
|
|
603
608
|
): ByteArray {
|
|
604
609
|
val w = ProtobufWriter()
|
|
605
610
|
w.writeInt32Field(
|
|
606
|
-
|
|
607
|
-
|
|
611
|
+
1,
|
|
612
|
+
DevCfgCommandId.RING_CONNECT_INFO.value
|
|
608
613
|
) // commandId = RING_CONNECT_INFO (6)
|
|
609
614
|
w.writeInt32Field(2, magicRandom)
|
|
610
615
|
|
|
@@ -856,6 +861,7 @@ private object MenuProto {
|
|
|
856
861
|
val appIdMap = mutableMapOf<Int, String>()
|
|
857
862
|
|
|
858
863
|
data class WireItem(val displayName: String?, val appId: Int, val isBuiltIn: Boolean)
|
|
864
|
+
|
|
859
865
|
val wireItems = mutableListOf<WireItem>()
|
|
860
866
|
|
|
861
867
|
// Always first: built-in Notification (SID=4)
|
|
@@ -867,8 +873,8 @@ private object MenuProto {
|
|
|
867
873
|
appIdMap[appId] = item.packageName
|
|
868
874
|
|
|
869
875
|
val truncated =
|
|
870
|
-
|
|
871
|
-
|
|
876
|
+
if (item.name.length > MAX_NAME_LENGTH) item.name.take(MAX_NAME_LENGTH)
|
|
877
|
+
else item.name
|
|
872
878
|
val prefix = if (item.running) "● " else " "
|
|
873
879
|
wireItems.add(WireItem(prefix + truncated, appId, false))
|
|
874
880
|
}
|
|
@@ -925,11 +931,11 @@ private object CalendarProto {
|
|
|
925
931
|
* which treat timestamps as already-local, display local time)
|
|
926
932
|
*/
|
|
927
933
|
private fun schedule(
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
934
|
+
scheduleId: Int,
|
|
935
|
+
title: String?,
|
|
936
|
+
location: String?,
|
|
937
|
+
time: String?,
|
|
938
|
+
endTimestamp: Int
|
|
933
939
|
): ByteArray {
|
|
934
940
|
val w = ProtobufWriter()
|
|
935
941
|
w.writeInt32Field(1, scheduleId)
|
|
@@ -958,16 +964,16 @@ private object CalendarProto {
|
|
|
958
964
|
* }
|
|
959
965
|
*/
|
|
960
966
|
fun calendarPush(
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
967
|
+
magicRandom: Int,
|
|
968
|
+
packageId: Int,
|
|
969
|
+
scheduleId: Int,
|
|
970
|
+
title: String?,
|
|
971
|
+
location: String?,
|
|
972
|
+
time: String?,
|
|
973
|
+
endTimestamp: Int,
|
|
974
|
+
scheduleAuthority: Int,
|
|
975
|
+
scheduleTotal: Int,
|
|
976
|
+
scheduleNum: Int
|
|
971
977
|
): ByteArray {
|
|
972
978
|
val sched = schedule(scheduleId, title, location, time, endTimestamp)
|
|
973
979
|
|
|
@@ -1028,10 +1034,10 @@ private object CalendarProto {
|
|
|
1028
1034
|
|
|
1029
1035
|
private object EvenBLETransport {
|
|
1030
1036
|
fun buildPackets(
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1037
|
+
syncId: Byte,
|
|
1038
|
+
serviceId: Byte,
|
|
1039
|
+
payload: ByteArray,
|
|
1040
|
+
reserveFlag: Boolean = false
|
|
1035
1041
|
): List<ByteArray> {
|
|
1036
1042
|
val maxPayload = G2BLE.MAX_PACKET_PAYLOAD
|
|
1037
1043
|
|
|
@@ -1069,7 +1075,7 @@ private object EvenBLETransport {
|
|
|
1069
1075
|
val packet = ByteArrayOutputStream()
|
|
1070
1076
|
packet.write(G2BLE.HEADER_BYTE.toInt() and 0xFF)
|
|
1071
1077
|
packet.write(
|
|
1072
|
-
|
|
1078
|
+
((G2BLE.DEST_GLASSES.toInt() shl 4) or G2BLE.SOURCE_PHONE.toInt()) and 0xFF
|
|
1073
1079
|
)
|
|
1074
1080
|
packet.write(syncId.toInt() and 0xFF)
|
|
1075
1081
|
packet.write(payloadLen.toInt() and 0xFF)
|
|
@@ -1111,9 +1117,9 @@ private class G2SendManager {
|
|
|
1111
1117
|
}
|
|
1112
1118
|
|
|
1113
1119
|
fun buildPackets(
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1120
|
+
serviceId: Byte,
|
|
1121
|
+
payload: ByteArray,
|
|
1122
|
+
reserveFlag: Boolean = false
|
|
1117
1123
|
): List<ByteArray> {
|
|
1118
1124
|
val sid = nextSyncId()
|
|
1119
1125
|
return EvenBLETransport.buildPackets(sid, serviceId, payload, reserveFlag)
|
|
@@ -1179,8 +1185,8 @@ private class G2ReceiveManager {
|
|
|
1179
1185
|
// ---------- G2 Reconnection Manager ----------
|
|
1180
1186
|
|
|
1181
1187
|
private class G2ReconnectionManager(
|
|
1182
|
-
|
|
1183
|
-
|
|
1188
|
+
private val intervalMs: Long = 30_000L,
|
|
1189
|
+
private val maxAttempts: Int = -1 // -1 for unlimited
|
|
1184
1190
|
) {
|
|
1185
1191
|
private val handler = Handler(Looper.getMainLooper())
|
|
1186
1192
|
private var runnable: Runnable? = null
|
|
@@ -1193,27 +1199,27 @@ private class G2ReconnectionManager(
|
|
|
1193
1199
|
attempts = 0
|
|
1194
1200
|
|
|
1195
1201
|
val r =
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
attempts++
|
|
1205
|
-
Bridge.log("G2: Reconnection attempt $attempts")
|
|
1202
|
+
object : Runnable {
|
|
1203
|
+
override fun run() {
|
|
1204
|
+
if (maxAttempts > 0 && attempts >= maxAttempts) {
|
|
1205
|
+
Bridge.log("G2: Max reconnection attempts ($maxAttempts) reached")
|
|
1206
|
+
stop()
|
|
1207
|
+
return
|
|
1208
|
+
}
|
|
1206
1209
|
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
Bridge.log("G2: Reconnection successful, stopping")
|
|
1210
|
-
stop()
|
|
1211
|
-
return
|
|
1212
|
-
}
|
|
1210
|
+
attempts++
|
|
1211
|
+
Bridge.log("G2: Reconnection attempt $attempts")
|
|
1213
1212
|
|
|
1214
|
-
|
|
1213
|
+
val shouldStop = onAttempt()
|
|
1214
|
+
if (shouldStop) {
|
|
1215
|
+
Bridge.log("G2: Reconnection successful, stopping")
|
|
1216
|
+
stop()
|
|
1217
|
+
return
|
|
1215
1218
|
}
|
|
1219
|
+
|
|
1220
|
+
handler.postDelayed(this, intervalMs)
|
|
1216
1221
|
}
|
|
1222
|
+
}
|
|
1217
1223
|
runnable = r
|
|
1218
1224
|
handler.postDelayed(r, intervalMs)
|
|
1219
1225
|
}
|
|
@@ -1259,36 +1265,37 @@ class G2 : SGCManager() {
|
|
|
1259
1265
|
|
|
1260
1266
|
// Device search
|
|
1261
1267
|
private var DEVICE_SEARCH_ID = "NOT_SET"
|
|
1268
|
+
|
|
1262
1269
|
// Map device names to serial numbers (populated from manufacturer data during scan)
|
|
1263
1270
|
private val deviceNameToSerialNumber = mutableMapOf<String, String>()
|
|
1264
1271
|
|
|
1265
1272
|
// Saved addresses for reconnection
|
|
1266
1273
|
private var leftGlassAddress: String?
|
|
1267
1274
|
get() =
|
|
1268
|
-
|
|
1269
|
-
|
|
1275
|
+
context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
|
1276
|
+
.getString(KEY_LEFT_ADDRESS, null)
|
|
1270
1277
|
set(value) {
|
|
1271
1278
|
context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
1279
|
+
.edit()
|
|
1280
|
+
.apply {
|
|
1281
|
+
if (value != null) putString(KEY_LEFT_ADDRESS, value)
|
|
1282
|
+
else remove(KEY_LEFT_ADDRESS)
|
|
1283
|
+
}
|
|
1284
|
+
.apply()
|
|
1278
1285
|
}
|
|
1279
1286
|
|
|
1280
1287
|
private var rightGlassAddress: String?
|
|
1281
1288
|
get() =
|
|
1282
|
-
|
|
1283
|
-
|
|
1289
|
+
context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
|
1290
|
+
.getString(KEY_RIGHT_ADDRESS, null)
|
|
1284
1291
|
set(value) {
|
|
1285
1292
|
context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
|
|
1291
|
-
|
|
1293
|
+
.edit()
|
|
1294
|
+
.apply {
|
|
1295
|
+
if (value != null) putString(KEY_RIGHT_ADDRESS, value)
|
|
1296
|
+
else remove(KEY_RIGHT_ADDRESS)
|
|
1297
|
+
}
|
|
1298
|
+
.apply()
|
|
1292
1299
|
}
|
|
1293
1300
|
|
|
1294
1301
|
// Reconnection
|
|
@@ -1300,23 +1307,61 @@ class G2 : SGCManager() {
|
|
|
1300
1307
|
private val mainHandler = Handler(Looper.getMainLooper())
|
|
1301
1308
|
private var heartbeatRunnable: Runnable? = null
|
|
1302
1309
|
private var devSettingsHeartbeatRunnable: Runnable? = null
|
|
1303
|
-
|
|
1304
|
-
|
|
1305
|
-
|
|
1306
|
-
private var lastEvenHubResendsRemaining: Int = 0
|
|
1310
|
+
|
|
1311
|
+
/** How many redundant resends each text update gets (text has no ACK). The reconcile loop sets a
|
|
1312
|
+
* container's [TextContainer.pendingSends] to `1 + EVEN_HUB_RESEND_COUNT` on change. */
|
|
1307
1313
|
private val EVEN_HUB_RESEND_COUNT: Int = 1
|
|
1314
|
+
|
|
1315
|
+
/** ~100ms idle-tick interval for the reconcile loop (text resends, image retries while idle). */
|
|
1308
1316
|
private val EVEN_HUB_QUEUE_TICK_MS = 100L
|
|
1309
1317
|
private var startupPageCreated: Boolean = false
|
|
1310
1318
|
private var pageCreated: Boolean = false
|
|
1319
|
+
// Live hardware truth: is the firmware mic actually streaming. DISTINCT from the
|
|
1320
|
+
// glasses/micEnabled DeviceStore flag, which is *intent* (does the user want the mic on).
|
|
1321
|
+
// Cleared on every page teardown WITHOUT touching intent, so recovery can re-arm iff intent
|
|
1322
|
+
// still says the mic should be on. See the iOS G2.swift counterpart.
|
|
1323
|
+
private var evenHubMicActive: Boolean = false
|
|
1311
1324
|
private var currentTextContent: String = ""
|
|
1312
1325
|
private var textContainerID: Int = 1
|
|
1313
1326
|
private var imageSessionCounter: Int = 0
|
|
1314
1327
|
private var heartbeatCounter: Int = 0
|
|
1328
|
+
|
|
1329
|
+
// Image-send ACK/retry: the glasses ACK EACH fragment with an ImgResCmd carrying MapSessionId
|
|
1330
|
+
// (field 3), MapFragmentIndex (field 6) and ErrorCode (field 8; 4=success, 5=failed). One
|
|
1331
|
+
// MapSessionId identifies the WHOLE image transfer (constant across its fragments) — the
|
|
1332
|
+
// glasses key their reassembly buffer on it — so each fragment reuses the same session id with
|
|
1333
|
+
// an incrementing MapFragmentIndex. We correlate a fragment's ACK by the (session,
|
|
1334
|
+
// fragmentIndex) pair; sendImageData() awaits it before sending the next fragment. Only one
|
|
1335
|
+
// transfer is ever outstanding (the reconcile loop is the sole sender of images), so a single
|
|
1336
|
+
// slot suffices.
|
|
1337
|
+
// @Volatile: written on the main thread (in sendImageData) but read/completed on the BLE
|
|
1338
|
+
// callback thread (in correlateImageAck) so the ACK is never delayed behind a backed-up main
|
|
1339
|
+
// queue. CompletableDeferred.complete() is itself thread-safe.
|
|
1340
|
+
@Volatile
|
|
1341
|
+
private var pendingImgAckSession: Int? = null
|
|
1342
|
+
|
|
1343
|
+
@Volatile
|
|
1344
|
+
private var pendingImgAckFragment: Int? = null
|
|
1345
|
+
|
|
1346
|
+
@Volatile
|
|
1347
|
+
private var pendingImgAck: CompletableDeferred<Boolean>? = null
|
|
1348
|
+
private val IMG_ACK_TIMEOUT_MS = 2000L // matches Dart host
|
|
1349
|
+
private val IMG_MAX_ATTEMPTS = 3
|
|
1315
1350
|
private var authStarted: Boolean = false
|
|
1316
1351
|
private var leftAuthenticated: Boolean = false
|
|
1317
1352
|
private var rightAuthenticated: Boolean = false
|
|
1318
1353
|
private var currentBitmapBase64: String = ""
|
|
1319
1354
|
private var dashboardShowing = 0
|
|
1355
|
+
// The 08011A00 gesture_ctrl event is ambiguous: the firmware sends it BOTH when the dashboard
|
|
1356
|
+
// opens (shuts our page down to take the screen) and when it closes. showDashboard() sets this
|
|
1357
|
+
// latch; the next 08011A00 is the OPEN confirm — consume it WITHOUT recovering (else we rebuild
|
|
1358
|
+
// and snatch the screen back). The following 08011A00 is the real CLOSE → recover. See iOS.
|
|
1359
|
+
private var dashboardOpening = false
|
|
1360
|
+
// Recovery throttle (see iOS G2.swift): the firmware spams systemExit + dashboard-close
|
|
1361
|
+
// ~1×/sec. Coalesce so recovery can't storm — one rebuild in flight, one per RECOVERY_DEBOUNCE_MS.
|
|
1362
|
+
private var recoveryInFlight = false
|
|
1363
|
+
private var lastRecoveryRebuildMs: Long = 0
|
|
1364
|
+
private val RECOVERY_DEBOUNCE_MS: Long = 1500
|
|
1320
1365
|
|
|
1321
1366
|
// Dashboard menu state
|
|
1322
1367
|
private var menuAppIdToPackageName: MutableMap<Int, String> = mutableMapOf()
|
|
@@ -1328,66 +1373,89 @@ class G2 : SGCManager() {
|
|
|
1328
1373
|
private var lastGestureCtrlTimestamp: Long? = null
|
|
1329
1374
|
|
|
1330
1375
|
/**
|
|
1331
|
-
*
|
|
1332
|
-
*
|
|
1333
|
-
*
|
|
1376
|
+
* Owns every display mutation. Each public display entry point launches on this single-threaded
|
|
1377
|
+
* Main scope and only mutates container state (content / bmpData / dirty flags), never sends
|
|
1378
|
+
* directly. The [displayReconcileJob] background loop — also on this scope — is the sole sender,
|
|
1379
|
+
* so exactly one image transfer is ever in flight by construction (no lock needed; matches the
|
|
1380
|
+
* iOS reconcile design that replaced DisplayMutex).
|
|
1334
1381
|
*/
|
|
1335
1382
|
private val displayScope = CoroutineScope(Dispatchers.Main + SupervisorJob())
|
|
1336
|
-
|
|
1383
|
+
|
|
1384
|
+
/** Background loop that owns ALL display sends (text + images): each pass pushes dirty text
|
|
1385
|
+
* containers, then dirty image containers one at a time. Display ops just mark containers dirty
|
|
1386
|
+
* and signal [displayDirtySignal]. */
|
|
1387
|
+
private var displayReconcileJob: Job? = null
|
|
1388
|
+
|
|
1389
|
+
/** ~100ms ticker that nudges the reconcile loop while idle, so text resends and image retries
|
|
1390
|
+
* still happen with no new mutations. Just sends into [displayDirtySignal]. */
|
|
1391
|
+
private var displayTickJob: Job? = null
|
|
1392
|
+
|
|
1393
|
+
/** Wakes the reconcile loop the instant a container is marked dirty, instead of waiting out the
|
|
1394
|
+
* idle tick. CONFLATED (1-deep, newest-wins) so a burst of mutations coalesces into one wake —
|
|
1395
|
+
* the Kotlin analogue of iOS's `AsyncStream(bufferingPolicy: .bufferingNewest(1))`. */
|
|
1396
|
+
private var displayDirtySignal: Channel<Unit>? = null
|
|
1337
1397
|
|
|
1338
1398
|
/** A tracked image container on the current page. Keyed by its rect for reuse. */
|
|
1339
1399
|
private data class ImgContainer(
|
|
1340
|
-
|
|
1341
|
-
|
|
1342
|
-
|
|
1343
|
-
|
|
1344
|
-
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
|
|
1348
|
-
|
|
1400
|
+
val id: Int,
|
|
1401
|
+
val x: Int,
|
|
1402
|
+
val y: Int,
|
|
1403
|
+
val width: Int,
|
|
1404
|
+
val height: Int,
|
|
1405
|
+
// The converted BMP cached so the container can be re-sent on a page rebuild.
|
|
1406
|
+
// NOTE: this ByteArray makes the auto-generated equals/hashCode unreliable, but the
|
|
1407
|
+
// class is only ever compared via matches()/id, so that is never relied upon.
|
|
1408
|
+
var bmpData: ByteArray,
|
|
1409
|
+
// Set true when bmpData changes and the new pixels haven't been pushed yet. The reconcile
|
|
1410
|
+
// loop is the sole sender; it clears this once the exact bytes it sent still match the
|
|
1411
|
+
// container. Lets every display op be a pure state mutation (no DisplayMutex needed).
|
|
1412
|
+
var dirty: Boolean = false
|
|
1349
1413
|
) {
|
|
1350
1414
|
val name: String
|
|
1351
1415
|
get() = "img-$id"
|
|
1352
1416
|
|
|
1353
1417
|
fun matches(x: Int, y: Int, width: Int, height: Int): Boolean =
|
|
1354
|
-
|
|
1418
|
+
this.x == x && this.y == y && this.width == width && this.height == height
|
|
1355
1419
|
}
|
|
1356
1420
|
|
|
1357
1421
|
/** A tracked text container on the current page. Keyed by its rect for reuse. */
|
|
1358
1422
|
private data class TextContainer(
|
|
1359
|
-
|
|
1360
|
-
|
|
1361
|
-
|
|
1362
|
-
|
|
1363
|
-
|
|
1364
|
-
|
|
1365
|
-
|
|
1366
|
-
|
|
1367
|
-
|
|
1368
|
-
|
|
1423
|
+
val id: Int,
|
|
1424
|
+
val x: Int,
|
|
1425
|
+
val y: Int,
|
|
1426
|
+
val width: Int,
|
|
1427
|
+
val height: Int,
|
|
1428
|
+
var content: String,
|
|
1429
|
+
val borderWidth: Int,
|
|
1430
|
+
val borderColor: Int,
|
|
1431
|
+
val borderRadius: Int,
|
|
1432
|
+
val paddingLength: Int,
|
|
1433
|
+
// Remaining sends the reconcile loop owes this container. Text has no ACK, so each content
|
|
1434
|
+
// change schedules `1 + EVEN_HUB_RESEND_COUNT` sends (the update + a redundant resend on a
|
|
1435
|
+
// later tick) as a delivery hedge; the loop sends once and decrements per tick until 0.
|
|
1436
|
+
var pendingSends: Int = 0
|
|
1369
1437
|
) {
|
|
1370
1438
|
val name: String
|
|
1371
1439
|
get() = "text-$id"
|
|
1372
1440
|
|
|
1373
1441
|
fun matches(
|
|
1374
|
-
|
|
1375
|
-
|
|
1376
|
-
|
|
1377
|
-
|
|
1378
|
-
|
|
1379
|
-
|
|
1380
|
-
|
|
1381
|
-
|
|
1442
|
+
x: Int,
|
|
1443
|
+
y: Int,
|
|
1444
|
+
width: Int,
|
|
1445
|
+
height: Int,
|
|
1446
|
+
borderWidth: Int,
|
|
1447
|
+
borderColor: Int,
|
|
1448
|
+
borderRadius: Int,
|
|
1449
|
+
paddingLength: Int
|
|
1382
1450
|
): Boolean =
|
|
1383
|
-
|
|
1384
|
-
|
|
1385
|
-
|
|
1386
|
-
|
|
1387
|
-
|
|
1388
|
-
|
|
1389
|
-
|
|
1390
|
-
|
|
1451
|
+
this.x == x &&
|
|
1452
|
+
this.y == y &&
|
|
1453
|
+
this.width == width &&
|
|
1454
|
+
this.height == height &&
|
|
1455
|
+
this.borderWidth == borderWidth &&
|
|
1456
|
+
this.borderColor == borderColor &&
|
|
1457
|
+
this.borderRadius == borderRadius &&
|
|
1458
|
+
this.paddingLength == paddingLength
|
|
1391
1459
|
}
|
|
1392
1460
|
|
|
1393
1461
|
/**
|
|
@@ -1396,14 +1464,17 @@ class G2 : SGCManager() {
|
|
|
1396
1464
|
*/
|
|
1397
1465
|
private val imageContainers: MutableList<ImgContainer> = mutableListOf()
|
|
1398
1466
|
private val textContainers: MutableList<TextContainer> = mutableListOf()
|
|
1467
|
+
|
|
1399
1468
|
/** Fixed pool of container IDs the page protocol expects. */
|
|
1400
1469
|
private val imageContainerIDPool: List<Int> = listOf(10, 11, 12, 13)
|
|
1401
1470
|
private val textContainerIDPool: List<Int> = listOf(1, 2, 3, 4, 5, 6)
|
|
1471
|
+
|
|
1402
1472
|
/** Default container seeded into every fresh page: 200x100 centered at 188,44. */
|
|
1403
1473
|
private val defaultImgX = 188
|
|
1404
1474
|
private val defaultImgY = 44
|
|
1405
1475
|
private val defaultImgWidth = 200
|
|
1406
1476
|
private val defaultImgHeight = 100
|
|
1477
|
+
|
|
1407
1478
|
/** Default text container: full-screen 576x288 with a 4px padding. */
|
|
1408
1479
|
private val defaultTextX = 0
|
|
1409
1480
|
private val defaultTextY = 0
|
|
@@ -1443,14 +1514,47 @@ class G2 : SGCManager() {
|
|
|
1443
1514
|
// Matches the 8 ms G1.java uses for its bitmap chunk loop (ANDROID_CHUNK_DELAY_MS).
|
|
1444
1515
|
private val BLE_PACKET_GAP_MS = 8L
|
|
1445
1516
|
|
|
1517
|
+
// Dedicated single-thread executor for pacing BLE packet bursts. We must NOT spread the burst
|
|
1518
|
+
// across [mainHandler]: the incoming image ACK is also delivered on the main thread, and a long
|
|
1519
|
+
// run of postDelayed writes (plus heartbeats and the text-queue tick) can push ACK processing
|
|
1520
|
+
// past IMG_ACK_TIMEOUT_MS — the glasses appear to "stop responding" even though the ACK arrived.
|
|
1521
|
+
// Pacing here keeps the main looper free so ACKs are processed promptly.
|
|
1522
|
+
private val bleWriteExecutor: java.util.concurrent.ExecutorService =
|
|
1523
|
+
java.util.concurrent.Executors.newSingleThreadExecutor { r ->
|
|
1524
|
+
Thread(r, "G2-ble-write").apply { isDaemon = true }
|
|
1525
|
+
}
|
|
1526
|
+
|
|
1446
1527
|
@Suppress("deprecation")
|
|
1528
|
+
// BGCAP diagnostic: Android G2's text path is already direct (single packet -> writeOnePacket),
|
|
1529
|
+
// so the iOS root cause (the canSend gate) does NOT exist here. This measures whether
|
|
1530
|
+
// writeCharacteristic is being REJECTED (returns false = Android GATT stack busy/throttled, the
|
|
1531
|
+
// packet is dropped) in the background — the suspected Android accumulation/loss point. The
|
|
1532
|
+
// current code discards the return value. Rate-limited; "BGCAP:" prefix; remove after the
|
|
1533
|
+
// Android repro pins the mechanism.
|
|
1534
|
+
private var bgcapWriteOk = 0
|
|
1535
|
+
private var bgcapWriteFail = 0
|
|
1536
|
+
private var bgcapWriteLogAt = 0L
|
|
1537
|
+
|
|
1538
|
+
private fun bgcapNoteWriteResult(ok: Boolean) {
|
|
1539
|
+
if (ok) bgcapWriteOk++ else bgcapWriteFail++
|
|
1540
|
+
val now = android.os.SystemClock.uptimeMillis()
|
|
1541
|
+
if (now - bgcapWriteLogAt >= 1000) {
|
|
1542
|
+
if (bgcapWriteOk > 0 || bgcapWriteFail > 0) {
|
|
1543
|
+
Bridge.log("BGCAP: g2 writeCharacteristic ok=$bgcapWriteOk fail=$bgcapWriteFail in ${now - bgcapWriteLogAt}ms (fail = stack busy/dropped)")
|
|
1544
|
+
}
|
|
1545
|
+
bgcapWriteOk = 0
|
|
1546
|
+
bgcapWriteFail = 0
|
|
1547
|
+
bgcapWriteLogAt = now
|
|
1548
|
+
}
|
|
1549
|
+
}
|
|
1550
|
+
|
|
1447
1551
|
private fun writeOnePacket(packet: ByteArray, left: Boolean, right: Boolean) {
|
|
1448
1552
|
if (right) {
|
|
1449
1553
|
rightWriteChar?.let { char ->
|
|
1450
1554
|
rightGatt?.let { gatt ->
|
|
1451
1555
|
char.value = packet
|
|
1452
1556
|
char.writeType = BluetoothGattCharacteristic.WRITE_TYPE_NO_RESPONSE
|
|
1453
|
-
gatt.writeCharacteristic(char)
|
|
1557
|
+
bgcapNoteWriteResult(gatt.writeCharacteristic(char)) // BGCAP
|
|
1454
1558
|
}
|
|
1455
1559
|
}
|
|
1456
1560
|
}
|
|
@@ -1459,16 +1563,16 @@ class G2 : SGCManager() {
|
|
|
1459
1563
|
leftGatt?.let { gatt ->
|
|
1460
1564
|
char.value = packet
|
|
1461
1565
|
char.writeType = BluetoothGattCharacteristic.WRITE_TYPE_NO_RESPONSE
|
|
1462
|
-
gatt.writeCharacteristic(char)
|
|
1566
|
+
bgcapNoteWriteResult(gatt.writeCharacteristic(char)) // BGCAP
|
|
1463
1567
|
}
|
|
1464
1568
|
}
|
|
1465
1569
|
}
|
|
1466
1570
|
}
|
|
1467
1571
|
|
|
1468
1572
|
private fun sendToGlasses(
|
|
1469
|
-
|
|
1470
|
-
|
|
1471
|
-
|
|
1573
|
+
packets: List<ByteArray>,
|
|
1574
|
+
left: Boolean = false,
|
|
1575
|
+
right: Boolean = true
|
|
1472
1576
|
) {
|
|
1473
1577
|
// Bridge.log("G2: sendToGlasses() - sending ${packets.size} packets first byte: ${packets[0][0]}")
|
|
1474
1578
|
if (packets.isEmpty()) return
|
|
@@ -1477,116 +1581,125 @@ class G2 : SGCManager() {
|
|
|
1477
1581
|
writeOnePacket(packets[0], left, right)
|
|
1478
1582
|
return
|
|
1479
1583
|
}
|
|
1480
|
-
// Multi-packet bursts (bitmaps, large protobufs):
|
|
1481
|
-
//
|
|
1482
|
-
//
|
|
1483
|
-
|
|
1484
|
-
|
|
1485
|
-
|
|
1486
|
-
|
|
1584
|
+
// Multi-packet bursts (bitmaps, large protobufs): pace the whole burst on a dedicated
|
|
1585
|
+
// write thread with a blocking gap between packets, so the Android BLE stack can drain each
|
|
1586
|
+
// write before the next is queued WITHOUT occupying the main looper (which also carries the
|
|
1587
|
+
// image ACK and would otherwise starve it under load — see [bleWriteExecutor]).
|
|
1588
|
+
bleWriteExecutor.execute {
|
|
1589
|
+
for (i in packets.indices) {
|
|
1590
|
+
writeOnePacket(packets[i], left, right)
|
|
1591
|
+
if (i < packets.size - 1) {
|
|
1592
|
+
try {
|
|
1593
|
+
Thread.sleep(BLE_PACKET_GAP_MS)
|
|
1594
|
+
} catch (_: InterruptedException) {
|
|
1595
|
+
Thread.currentThread().interrupt()
|
|
1596
|
+
return@execute
|
|
1597
|
+
}
|
|
1598
|
+
}
|
|
1599
|
+
}
|
|
1487
1600
|
}
|
|
1488
1601
|
}
|
|
1489
1602
|
|
|
1490
1603
|
private fun sendEvenHubCommand(payload: ByteArray) {
|
|
1491
1604
|
val packets =
|
|
1492
|
-
|
|
1493
|
-
|
|
1494
|
-
|
|
1495
|
-
|
|
1496
|
-
|
|
1497
|
-
sendToGlasses(packets
|
|
1605
|
+
sendManager.buildPackets(
|
|
1606
|
+
serviceId = ServiceID.EVEN_HUB.value,
|
|
1607
|
+
payload = payload,
|
|
1608
|
+
reserveFlag = true
|
|
1609
|
+
)
|
|
1610
|
+
sendToGlasses(packets)
|
|
1498
1611
|
}
|
|
1499
1612
|
|
|
1500
1613
|
private fun sendDevSettingsCommand(
|
|
1501
|
-
|
|
1502
|
-
|
|
1503
|
-
|
|
1614
|
+
payload: ByteArray,
|
|
1615
|
+
left: Boolean = false,
|
|
1616
|
+
right: Boolean = true
|
|
1504
1617
|
) {
|
|
1505
1618
|
val packets =
|
|
1506
|
-
|
|
1507
|
-
|
|
1508
|
-
|
|
1509
|
-
|
|
1619
|
+
sendManager.buildPackets(
|
|
1620
|
+
serviceId = ServiceID.DEVICE_SETTINGS.value,
|
|
1621
|
+
payload = payload
|
|
1622
|
+
)
|
|
1510
1623
|
sendToGlasses(packets, left = left, right = right)
|
|
1511
1624
|
}
|
|
1512
1625
|
|
|
1513
1626
|
private fun sendNavigationCommand(payload: ByteArray) {
|
|
1514
1627
|
val packets =
|
|
1515
|
-
|
|
1516
|
-
|
|
1517
|
-
|
|
1518
|
-
|
|
1519
|
-
|
|
1628
|
+
sendManager.buildPackets(
|
|
1629
|
+
serviceId = ServiceID.NAVIGATION.value,
|
|
1630
|
+
payload = payload,
|
|
1631
|
+
reserveFlag = true
|
|
1632
|
+
)
|
|
1520
1633
|
sendToGlasses(packets)
|
|
1521
1634
|
}
|
|
1522
1635
|
|
|
1523
1636
|
private fun sendG2SettingCommand(payload: ByteArray) {
|
|
1524
1637
|
val packets =
|
|
1525
|
-
|
|
1526
|
-
|
|
1527
|
-
|
|
1528
|
-
|
|
1529
|
-
|
|
1638
|
+
sendManager.buildPackets(
|
|
1639
|
+
serviceId = ServiceID.G2_SETTING.value,
|
|
1640
|
+
payload = payload,
|
|
1641
|
+
reserveFlag = true
|
|
1642
|
+
)
|
|
1530
1643
|
sendToGlasses(packets)
|
|
1531
1644
|
}
|
|
1532
1645
|
|
|
1533
1646
|
private fun sendOnboardingCommand(payload: ByteArray) {
|
|
1534
1647
|
val packets =
|
|
1535
|
-
|
|
1536
|
-
|
|
1537
|
-
|
|
1538
|
-
|
|
1539
|
-
|
|
1648
|
+
sendManager.buildPackets(
|
|
1649
|
+
serviceId = ServiceID.ONBOARDING.value,
|
|
1650
|
+
payload = payload,
|
|
1651
|
+
reserveFlag = true
|
|
1652
|
+
)
|
|
1540
1653
|
sendToGlasses(packets)
|
|
1541
1654
|
}
|
|
1542
1655
|
|
|
1543
1656
|
private fun sendEvenAICommand(payload: ByteArray) {
|
|
1544
1657
|
val packets =
|
|
1545
|
-
|
|
1546
|
-
|
|
1547
|
-
|
|
1548
|
-
|
|
1549
|
-
|
|
1658
|
+
sendManager.buildPackets(
|
|
1659
|
+
serviceId = ServiceID.EVEN_AI.value,
|
|
1660
|
+
payload = payload,
|
|
1661
|
+
reserveFlag = true
|
|
1662
|
+
)
|
|
1550
1663
|
sendToGlasses(packets)
|
|
1551
1664
|
}
|
|
1552
1665
|
|
|
1553
1666
|
private fun sendMenuCommand(payload: ByteArray) {
|
|
1554
1667
|
val packets =
|
|
1555
|
-
|
|
1556
|
-
|
|
1557
|
-
|
|
1558
|
-
|
|
1559
|
-
|
|
1668
|
+
sendManager.buildPackets(
|
|
1669
|
+
serviceId = ServiceID.MENU.value,
|
|
1670
|
+
payload = payload,
|
|
1671
|
+
reserveFlag = true
|
|
1672
|
+
)
|
|
1560
1673
|
sendToGlasses(packets)
|
|
1561
1674
|
}
|
|
1562
1675
|
|
|
1563
1676
|
private fun sendGestureCtrlCommand(payload: ByteArray) {
|
|
1564
1677
|
val packets =
|
|
1565
|
-
|
|
1566
|
-
|
|
1567
|
-
|
|
1568
|
-
|
|
1569
|
-
|
|
1678
|
+
sendManager.buildPackets(
|
|
1679
|
+
serviceId = ServiceID.GESTURE_CTRL.value,
|
|
1680
|
+
payload = payload,
|
|
1681
|
+
reserveFlag = true
|
|
1682
|
+
)
|
|
1570
1683
|
sendToGlasses(packets)
|
|
1571
1684
|
}
|
|
1572
1685
|
|
|
1573
1686
|
private fun sendEvenHubCtrlCommand(payload: ByteArray) {
|
|
1574
1687
|
val packets =
|
|
1575
|
-
|
|
1576
|
-
|
|
1577
|
-
|
|
1578
|
-
|
|
1579
|
-
|
|
1688
|
+
sendManager.buildPackets(
|
|
1689
|
+
serviceId = ServiceID.EVEN_HUB_CTRL.value,
|
|
1690
|
+
payload = payload,
|
|
1691
|
+
reserveFlag = true
|
|
1692
|
+
)
|
|
1580
1693
|
sendToGlasses(packets)
|
|
1581
1694
|
}
|
|
1582
1695
|
|
|
1583
1696
|
private fun sendDashboardCommand(payload: ByteArray) {
|
|
1584
1697
|
val packets =
|
|
1585
|
-
|
|
1586
|
-
|
|
1587
|
-
|
|
1588
|
-
|
|
1589
|
-
|
|
1698
|
+
sendManager.buildPackets(
|
|
1699
|
+
serviceId = ServiceID.DASHBOARD.value,
|
|
1700
|
+
payload = payload,
|
|
1701
|
+
reserveFlag = true
|
|
1702
|
+
)
|
|
1590
1703
|
sendToGlasses(packets)
|
|
1591
1704
|
}
|
|
1592
1705
|
|
|
@@ -1632,11 +1745,11 @@ class G2 : SGCManager() {
|
|
|
1632
1745
|
uiSettW.writeInt32Field(2, sendManager.nextMagicRandom())
|
|
1633
1746
|
uiSettW.writeMessageField(4, byteArrayOf(0x08, 0x01, 0x10, 0x00)) // {1:1, 2:0}
|
|
1634
1747
|
sendToGlasses(
|
|
1635
|
-
|
|
1636
|
-
|
|
1637
|
-
|
|
1638
|
-
|
|
1639
|
-
|
|
1748
|
+
sendManager.buildPackets(
|
|
1749
|
+
serviceId = 0x0C,
|
|
1750
|
+
payload = uiSettW.toByteArray(),
|
|
1751
|
+
reserveFlag = true
|
|
1752
|
+
)
|
|
1640
1753
|
)
|
|
1641
1754
|
|
|
1642
1755
|
// 6. Dashboard init (0x01) — display settings
|
|
@@ -1649,8 +1762,8 @@ class G2 : SGCManager() {
|
|
|
1649
1762
|
dashDisplayW.writeInt32Field(4, 4) // widgetDisplayCount
|
|
1650
1763
|
// WidgetType: 1=News, 2=Stock, 3=Schedule, 4=Quicklist, 5=Health
|
|
1651
1764
|
dashDisplayW.writeMessageField(
|
|
1652
|
-
|
|
1653
|
-
|
|
1765
|
+
5,
|
|
1766
|
+
byteArrayOf(3, 1, 2, 4, 5)
|
|
1654
1767
|
) // widgetDisplayOrder: Schedule, News, Stock, Quicklist
|
|
1655
1768
|
dashDisplayW.writeInt32Field(6, dashboardHalfDayFormat()) // halfDayFormat
|
|
1656
1769
|
dashDisplayW.writeInt32Field(7, dashboardTemperatureUnit()) // temperatureUnit
|
|
@@ -1748,36 +1861,47 @@ class G2 : SGCManager() {
|
|
|
1748
1861
|
stopHeartbeats()
|
|
1749
1862
|
|
|
1750
1863
|
val hbRunnable =
|
|
1751
|
-
|
|
1752
|
-
|
|
1753
|
-
|
|
1754
|
-
|
|
1755
|
-
}
|
|
1864
|
+
object : Runnable {
|
|
1865
|
+
override fun run() {
|
|
1866
|
+
sendEvenHubHeartbeat()
|
|
1867
|
+
mainHandler.postDelayed(this, 10000)
|
|
1756
1868
|
}
|
|
1869
|
+
}
|
|
1757
1870
|
heartbeatRunnable = hbRunnable
|
|
1758
1871
|
mainHandler.postDelayed(hbRunnable, 10000)
|
|
1759
1872
|
|
|
1760
1873
|
// DevSettings heartbeat every 5 seconds
|
|
1761
1874
|
val dsRunnable =
|
|
1762
|
-
|
|
1763
|
-
|
|
1764
|
-
|
|
1765
|
-
|
|
1766
|
-
}
|
|
1875
|
+
object : Runnable {
|
|
1876
|
+
override fun run() {
|
|
1877
|
+
sendDevSettingsHeartbeat()
|
|
1878
|
+
mainHandler.postDelayed(this, 5000)
|
|
1767
1879
|
}
|
|
1880
|
+
}
|
|
1768
1881
|
devSettingsHeartbeatRunnable = dsRunnable
|
|
1769
1882
|
mainHandler.postDelayed(dsRunnable, 5000)
|
|
1770
1883
|
|
|
1771
|
-
//
|
|
1772
|
-
|
|
1773
|
-
|
|
1774
|
-
|
|
1775
|
-
|
|
1776
|
-
|
|
1777
|
-
|
|
1884
|
+
// Display reconcile loop: push any dirty text containers (one send each, with a redundant
|
|
1885
|
+
// resend), then any dirty image containers one at a time. Single sender, so a sendImageData
|
|
1886
|
+
// never overlaps another and can't clobber the pending image ACK — the invariant displayMutex
|
|
1887
|
+
// used to enforce. The loop wakes on each channel element: mutations send one immediately
|
|
1888
|
+
// (instant reaction), and a ~100ms ticker sends one when idle so periodic work (text resends,
|
|
1889
|
+
// image retries) still runs. The CONFLATED buffer coalesces bursts into a single wake.
|
|
1890
|
+
val signal = Channel<Unit>(Channel.CONFLATED)
|
|
1891
|
+
displayDirtySignal = signal
|
|
1892
|
+
displayTickJob =
|
|
1893
|
+
displayScope.launch {
|
|
1894
|
+
while (isActive) {
|
|
1895
|
+
delay(EVEN_HUB_QUEUE_TICK_MS)
|
|
1896
|
+
signal.trySend(Unit)
|
|
1897
|
+
}
|
|
1898
|
+
}
|
|
1899
|
+
displayReconcileJob =
|
|
1900
|
+
displayScope.launch {
|
|
1901
|
+
for (sig in signal) {
|
|
1902
|
+
reconcileDisplay()
|
|
1778
1903
|
}
|
|
1779
|
-
|
|
1780
|
-
mainHandler.postDelayed(queueRunnable, EVEN_HUB_QUEUE_TICK_MS)
|
|
1904
|
+
}
|
|
1781
1905
|
}
|
|
1782
1906
|
|
|
1783
1907
|
private fun stopHeartbeats() {
|
|
@@ -1785,11 +1909,19 @@ class G2 : SGCManager() {
|
|
|
1785
1909
|
heartbeatRunnable = null
|
|
1786
1910
|
devSettingsHeartbeatRunnable?.let { mainHandler.removeCallbacks(it) }
|
|
1787
1911
|
devSettingsHeartbeatRunnable = null
|
|
1788
|
-
|
|
1789
|
-
|
|
1790
|
-
|
|
1791
|
-
|
|
1792
|
-
|
|
1912
|
+
displayTickJob?.cancel()
|
|
1913
|
+
displayTickJob = null
|
|
1914
|
+
displayReconcileJob?.cancel()
|
|
1915
|
+
displayReconcileJob = null
|
|
1916
|
+
displayDirtySignal?.close()
|
|
1917
|
+
displayDirtySignal = null
|
|
1918
|
+
}
|
|
1919
|
+
|
|
1920
|
+
/** Wake the reconcile loop now (a container was just marked dirty / had sends scheduled). Cheap
|
|
1921
|
+
* and idempotent: coalesced by the conflated channel, so a burst of mutations wakes at most once
|
|
1922
|
+
* extra. */
|
|
1923
|
+
private fun signalDisplayDirty() {
|
|
1924
|
+
displayDirtySignal?.trySend(Unit)
|
|
1793
1925
|
}
|
|
1794
1926
|
|
|
1795
1927
|
private fun sendEvenHubHeartbeat() {
|
|
@@ -1824,7 +1956,7 @@ class G2 : SGCManager() {
|
|
|
1824
1956
|
|
|
1825
1957
|
private fun sendMenuApps() {
|
|
1826
1958
|
val menuItems =
|
|
1827
|
-
|
|
1959
|
+
DeviceStore.get("bluetooth", "menu_apps") as? List<Map<String, Any>> ?: emptyList()
|
|
1828
1960
|
if (menuItems.isNotEmpty()) {
|
|
1829
1961
|
setDashboardMenu(menuItems)
|
|
1830
1962
|
}
|
|
@@ -1832,8 +1964,8 @@ class G2 : SGCManager() {
|
|
|
1832
1964
|
|
|
1833
1965
|
private fun sendStoredCalendarEvents() {
|
|
1834
1966
|
val calendarEvents =
|
|
1835
|
-
|
|
1836
|
-
|
|
1967
|
+
DeviceStore.get("bluetooth", "calendar_events") as? List<Map<String, Any>>
|
|
1968
|
+
?: emptyList()
|
|
1837
1969
|
sendCalendarEvents(calendarEvents)
|
|
1838
1970
|
}
|
|
1839
1971
|
|
|
@@ -1841,46 +1973,66 @@ class G2 : SGCManager() {
|
|
|
1841
1973
|
|
|
1842
1974
|
override fun sendText(text: String) {
|
|
1843
1975
|
displayScope.launch {
|
|
1844
|
-
|
|
1845
|
-
sendText2(text)
|
|
1846
|
-
}
|
|
1976
|
+
sendText2(text)
|
|
1847
1977
|
}
|
|
1848
1978
|
}
|
|
1849
1979
|
|
|
1850
1980
|
override fun sendTextWall(text: String) {
|
|
1851
1981
|
displayScope.launch {
|
|
1852
|
-
|
|
1853
|
-
|
|
1854
|
-
|
|
1855
|
-
|
|
1856
|
-
|
|
1857
|
-
|
|
1858
|
-
|
|
1859
|
-
|
|
1860
|
-
|
|
1861
|
-
|
|
1862
|
-
|
|
1863
|
-
|
|
1864
|
-
|
|
1982
|
+
sendText2(
|
|
1983
|
+
text,
|
|
1984
|
+
x = defaultTextX,
|
|
1985
|
+
y = defaultTextY,
|
|
1986
|
+
width = defaultTextWidth,
|
|
1987
|
+
height = defaultTextHeight,
|
|
1988
|
+
borderWidth = defaultTextBorderWidth,
|
|
1989
|
+
borderColor = defaultTextBorderColor,
|
|
1990
|
+
borderRadius = defaultTextBorderRadius,
|
|
1991
|
+
paddingLength = defaultTextPaddingLength
|
|
1992
|
+
)
|
|
1993
|
+
}
|
|
1994
|
+
}
|
|
1995
|
+
|
|
1996
|
+
override fun sendPositionedText(
|
|
1997
|
+
text: String,
|
|
1998
|
+
x: Int,
|
|
1999
|
+
y: Int,
|
|
2000
|
+
width: Int,
|
|
2001
|
+
height: Int,
|
|
2002
|
+
borderWidth: Int,
|
|
2003
|
+
borderRadius: Int
|
|
2004
|
+
) {
|
|
2005
|
+
displayScope.launch {
|
|
2006
|
+
sendText2(
|
|
2007
|
+
text,
|
|
2008
|
+
x = x,
|
|
2009
|
+
y = y,
|
|
2010
|
+
width = width,
|
|
2011
|
+
height = height,
|
|
2012
|
+
borderWidth = borderWidth,
|
|
2013
|
+
borderColor = defaultTextBorderColor,
|
|
2014
|
+
borderRadius = borderRadius,
|
|
2015
|
+
paddingLength = defaultTextPaddingLength
|
|
2016
|
+
)
|
|
1865
2017
|
}
|
|
1866
2018
|
}
|
|
1867
2019
|
|
|
1868
2020
|
private suspend fun sendText2(
|
|
1869
|
-
|
|
1870
|
-
|
|
1871
|
-
|
|
1872
|
-
|
|
1873
|
-
|
|
1874
|
-
|
|
1875
|
-
|
|
1876
|
-
|
|
1877
|
-
|
|
2021
|
+
text: String,
|
|
2022
|
+
x: Int? = null,
|
|
2023
|
+
y: Int? = null,
|
|
2024
|
+
width: Int? = null,
|
|
2025
|
+
height: Int? = null,
|
|
2026
|
+
borderWidth: Int? = null,
|
|
2027
|
+
borderColor: Int? = null,
|
|
2028
|
+
borderRadius: Int? = null,
|
|
2029
|
+
paddingLength: Int? = null
|
|
1878
2030
|
) {
|
|
1879
2031
|
// Bridge.log("G2: sendTextWall(${text.take(50)}...)")
|
|
1880
2032
|
|
|
1881
2033
|
// ignore events while the ER dashboard is open:
|
|
1882
2034
|
val useNativeDashboard =
|
|
1883
|
-
|
|
2035
|
+
DeviceStore.get("bluetooth", "use_native_dashboard") as? Boolean ?: false
|
|
1884
2036
|
if (useNativeDashboard && dashboardShowing > 0) {
|
|
1885
2037
|
return
|
|
1886
2038
|
}
|
|
@@ -1895,53 +2047,60 @@ class G2 : SGCManager() {
|
|
|
1895
2047
|
val rPaddingLength = defaultTextPaddingLength
|
|
1896
2048
|
val content = if (text.isEmpty()) " " else text
|
|
1897
2049
|
|
|
1898
|
-
//
|
|
2050
|
+
// Pure state mutation: update the container's content and schedule its sends; the reconcile
|
|
2051
|
+
// loop does the actual updateText writes. Reuse an existing container if the rect matches
|
|
2052
|
+
// exactly; otherwise add a new one.
|
|
1899
2053
|
val container: TextContainer
|
|
1900
2054
|
val existingIndex =
|
|
1901
|
-
|
|
1902
|
-
|
|
1903
|
-
|
|
1904
|
-
|
|
1905
|
-
|
|
1906
|
-
|
|
1907
|
-
|
|
1908
|
-
|
|
1909
|
-
|
|
1910
|
-
|
|
1911
|
-
|
|
1912
|
-
|
|
2055
|
+
textContainers.indexOfFirst {
|
|
2056
|
+
it.matches(
|
|
2057
|
+
rx,
|
|
2058
|
+
ry,
|
|
2059
|
+
rw,
|
|
2060
|
+
rh,
|
|
2061
|
+
rBorderWidth,
|
|
2062
|
+
rBorderColor,
|
|
2063
|
+
rBorderRadius,
|
|
2064
|
+
rPaddingLength
|
|
2065
|
+
)
|
|
2066
|
+
}
|
|
1913
2067
|
if (existingIndex >= 0) {
|
|
1914
2068
|
textContainers[existingIndex].content = content
|
|
2069
|
+
textContainers[existingIndex].pendingSends = 1 + EVEN_HUB_RESEND_COUNT
|
|
1915
2070
|
container = textContainers[existingIndex]
|
|
1916
|
-
|
|
2071
|
+
// Wake the reconcile loop either way. When the page is live it sends the text; when the
|
|
2072
|
+
// page is down the loop coalesces the burst into a single rebuild (see reconcileDisplay)
|
|
2073
|
+
// instead of one shutdown/rebuild per caption. The container's content is overwritten in
|
|
2074
|
+
// place (last-wins), so a backlog that piled up while we were suspended collapses to one
|
|
2075
|
+
// catch-up render — no flood on resume.
|
|
2076
|
+
signalDisplayDirty()
|
|
1917
2077
|
if (!pageCreated) {
|
|
1918
|
-
|
|
2078
|
+
Bridge.log("G2: sendText() - page down, buffering latest content for container ${container.id} (rebuild deferred to reconcile)")
|
|
1919
2079
|
return
|
|
1920
2080
|
}
|
|
1921
|
-
|
|
1922
|
-
val msg =
|
|
1923
|
-
EvenHubProto.updateTextMessage(
|
|
1924
|
-
containerID = container.id,
|
|
1925
|
-
contentOffset = 0,
|
|
1926
|
-
contentLength = container.content.toByteArray(Charsets.UTF_8).size,
|
|
1927
|
-
content = container.content
|
|
1928
|
-
)
|
|
1929
|
-
queueEvenHubCommand(msg)
|
|
2081
|
+
Bridge.log("G2: sendText() - reusing container ${container.id} for rect $rx,$ry ${rw}x$rh")
|
|
1930
2082
|
return
|
|
1931
2083
|
}
|
|
1932
2084
|
container =
|
|
1933
|
-
|
|
1934
|
-
|
|
1935
|
-
|
|
1936
|
-
|
|
1937
|
-
|
|
1938
|
-
|
|
1939
|
-
|
|
1940
|
-
|
|
1941
|
-
|
|
1942
|
-
|
|
1943
|
-
|
|
2085
|
+
addTextContainer(
|
|
2086
|
+
rx,
|
|
2087
|
+
ry,
|
|
2088
|
+
rw,
|
|
2089
|
+
rh,
|
|
2090
|
+
content,
|
|
2091
|
+
rBorderWidth,
|
|
2092
|
+
rBorderColor,
|
|
2093
|
+
rBorderRadius,
|
|
2094
|
+
rPaddingLength
|
|
2095
|
+
)
|
|
1944
2096
|
Bridge.log("G2: sendText() - added text container ${container.id} for rect $rx,$ry ${rw}x$rh, rebuilding page")
|
|
2097
|
+
// New container changes page structure: rebuild it (the rebuild embeds initial content), then
|
|
2098
|
+
// schedule sends so the loop refreshes it.
|
|
2099
|
+
val newIndex = textContainers.indexOfFirst { it.id == container.id }
|
|
2100
|
+
if (newIndex >= 0) {
|
|
2101
|
+
textContainers[newIndex].pendingSends = 1 + EVEN_HUB_RESEND_COUNT
|
|
2102
|
+
}
|
|
2103
|
+
signalDisplayDirty()
|
|
1945
2104
|
rebuildPage()
|
|
1946
2105
|
}
|
|
1947
2106
|
|
|
@@ -1954,19 +2113,29 @@ class G2 : SGCManager() {
|
|
|
1954
2113
|
|
|
1955
2114
|
override fun clearDisplay() {
|
|
1956
2115
|
Bridge.log("G2: clearDisplay()")
|
|
1957
|
-
|
|
1958
|
-
|
|
1959
|
-
|
|
1960
|
-
|
|
1961
|
-
|
|
1962
|
-
|
|
1963
|
-
|
|
1964
|
-
|
|
1965
|
-
|
|
1966
|
-
|
|
1967
|
-
|
|
2116
|
+
// Clear the text in place — do NOT shut down + rebuild the EvenHub page. Tearing the page
|
|
2117
|
+
// down (rebuildPage) kills audio streaming AND triggers a firmware systemExit /
|
|
2118
|
+
// dashboard-close → recovery → another rebuild. The cloud sends clearDisplay in bursts
|
|
2119
|
+
// (caption gaps → clear → new caption), and when each clear tore down + rebuilt the page
|
|
2120
|
+
// those bursts became a rebuild storm that churned the display and dropped audio for
|
|
2121
|
+
// several seconds (incident 8164175a). Just blank the text + clear images; the reconcile
|
|
2122
|
+
// loop pushes the blanked text on a live page, and a dead page is only resurrected for
|
|
2123
|
+
// meaningful (non-blank) content, so a clear can't churn it back up.
|
|
2124
|
+
for (i in textContainers.indices) {
|
|
2125
|
+
textContainers[i].content = " "
|
|
2126
|
+
textContainers[i].pendingSends = 1 + EVEN_HUB_RESEND_COUNT
|
|
2127
|
+
}
|
|
2128
|
+
for (i in imageContainers.indices) {
|
|
2129
|
+
// The firmware still shows this container's image; emptying bmpData locally never reaches
|
|
2130
|
+
// it (#3232 dropped the teardown that used to drop empty containers on rebuild). Empty +
|
|
2131
|
+
// dirty tells the reconcile loop to push an all-black frame that overwrites the image
|
|
2132
|
+
// on-glass — the page stays up, so no audio/mic churn. Skip already-empty containers.
|
|
2133
|
+
if (imageContainers[i].bmpData.isNotEmpty()) {
|
|
2134
|
+
imageContainers[i].bmpData = ByteArray(0)
|
|
2135
|
+
imageContainers[i].dirty = true
|
|
1968
2136
|
}
|
|
1969
2137
|
}
|
|
2138
|
+
signalDisplayDirty()
|
|
1970
2139
|
}
|
|
1971
2140
|
|
|
1972
2141
|
/**
|
|
@@ -1981,17 +2150,17 @@ class G2 : SGCManager() {
|
|
|
1981
2150
|
* Omitted params default to a 100x100 container in the top-left corner.
|
|
1982
2151
|
*/
|
|
1983
2152
|
override fun displayBitmap(
|
|
1984
|
-
|
|
1985
|
-
|
|
1986
|
-
|
|
1987
|
-
|
|
1988
|
-
|
|
2153
|
+
base64ImageData: String,
|
|
2154
|
+
x: Int?,
|
|
2155
|
+
y: Int?,
|
|
2156
|
+
width: Int?,
|
|
2157
|
+
height: Int?
|
|
1989
2158
|
): Boolean {
|
|
1990
2159
|
val rx = x ?: defaultImgX
|
|
1991
2160
|
val ry = y ?: defaultImgY
|
|
1992
2161
|
val rw = width ?: defaultImgWidth
|
|
1993
2162
|
val rh = height ?: defaultImgHeight
|
|
1994
|
-
|
|
2163
|
+
|
|
1995
2164
|
// ignore events while the ER dashboard is open:
|
|
1996
2165
|
val useNativeDashboard = DeviceStore.get("bluetooth", "use_native_dashboard") as? Boolean ?: false
|
|
1997
2166
|
if (useNativeDashboard && dashboardShowing > 0) {
|
|
@@ -1999,44 +2168,47 @@ class G2 : SGCManager() {
|
|
|
1999
2168
|
}
|
|
2000
2169
|
|
|
2001
2170
|
val rawData =
|
|
2002
|
-
|
|
2003
|
-
|
|
2004
|
-
|
|
2005
|
-
|
|
2006
|
-
|
|
2171
|
+
Base64.decode(base64ImageData, Base64.DEFAULT)
|
|
2172
|
+
?: run {
|
|
2173
|
+
Bridge.log("G2: displayBitmap() - failed to decode base64")
|
|
2174
|
+
return false
|
|
2175
|
+
}
|
|
2007
2176
|
|
|
2008
2177
|
val bmpData =
|
|
2009
|
-
|
|
2010
|
-
|
|
2011
|
-
|
|
2012
|
-
|
|
2013
|
-
|
|
2178
|
+
convertToG2Bmp(rawData, containerWidth = rw, containerHeight = rh)
|
|
2179
|
+
?: run {
|
|
2180
|
+
Bridge.log("G2: displayBitmap() - failed to convert image to BMP")
|
|
2181
|
+
return false
|
|
2182
|
+
}
|
|
2014
2183
|
|
|
2015
|
-
//
|
|
2184
|
+
// Pure state mutation: update the target container's bytes and mark it dirty. The reconcile
|
|
2185
|
+
// loop is the sole sender, so two displayBitmap calls can never overlap a sendImageData and
|
|
2186
|
+
// clobber the single-slot image ACK — no lock needed. Reuse an existing container if the rect
|
|
2187
|
+
// matches exactly; otherwise add a new one.
|
|
2016
2188
|
val container: ImgContainer
|
|
2017
2189
|
val existingIndex = imageContainers.indexOfFirst { it.matches(rx, ry, rw, rh) }
|
|
2018
2190
|
if (existingIndex >= 0) {
|
|
2019
2191
|
imageContainers[existingIndex].bmpData = bmpData
|
|
2192
|
+
imageContainers[existingIndex].dirty = true
|
|
2193
|
+
signalDisplayDirty()
|
|
2020
2194
|
container = imageContainers[existingIndex]
|
|
2021
2195
|
Bridge.log("G2: displayBitmap() - reusing container ${container.id} for rect $rx,$ry ${rw}x$rh")
|
|
2022
|
-
|
|
2023
|
-
|
|
2024
|
-
|
|
2025
|
-
|
|
2026
|
-
} else {
|
|
2027
|
-
sendImageData(container.id, container.name, container.bmpData)
|
|
2028
|
-
}
|
|
2029
|
-
}
|
|
2196
|
+
// A brand-new page needs its structure built before the loop can push pixels; the dirty
|
|
2197
|
+
// flag stays set so the reconcile loop sends the image once the page exists.
|
|
2198
|
+
if (!pageCreated) {
|
|
2199
|
+
displayScope.launch { rebuildPage() }
|
|
2030
2200
|
}
|
|
2031
2201
|
return true
|
|
2032
2202
|
} else {
|
|
2033
2203
|
container = addImageContainer(rx, ry, rw, rh, bmpData)
|
|
2034
|
-
|
|
2035
|
-
|
|
2036
|
-
|
|
2037
|
-
rebuildPage()
|
|
2038
|
-
}
|
|
2204
|
+
val newIndex = imageContainers.indexOfFirst { it.id == container.id }
|
|
2205
|
+
if (newIndex >= 0) {
|
|
2206
|
+
imageContainers[newIndex].dirty = true
|
|
2039
2207
|
}
|
|
2208
|
+
signalDisplayDirty()
|
|
2209
|
+
Bridge.log("G2: displayBitmap() - added container ${container.id} for rect $rx,$ry ${rw}x$rh, rebuilding page")
|
|
2210
|
+
// New container changes page structure: rebuild it, then the loop sends the pixels.
|
|
2211
|
+
displayScope.launch { rebuildPage() }
|
|
2040
2212
|
}
|
|
2041
2213
|
|
|
2042
2214
|
return true
|
|
@@ -2047,11 +2219,11 @@ class G2 : SGCManager() {
|
|
|
2047
2219
|
* Returns the newly tracked container (with an assigned ID from the pool).
|
|
2048
2220
|
*/
|
|
2049
2221
|
private fun addImageContainer(
|
|
2050
|
-
|
|
2051
|
-
|
|
2052
|
-
|
|
2053
|
-
|
|
2054
|
-
|
|
2222
|
+
x: Int,
|
|
2223
|
+
y: Int,
|
|
2224
|
+
width: Int,
|
|
2225
|
+
height: Int,
|
|
2226
|
+
bmpData: ByteArray
|
|
2055
2227
|
): ImgContainer {
|
|
2056
2228
|
// Evict the oldest container when at capacity, freeing its ID for reuse.
|
|
2057
2229
|
if (imageContainers.size >= imageContainerIDPool.size) {
|
|
@@ -2062,21 +2234,21 @@ class G2 : SGCManager() {
|
|
|
2062
2234
|
val usedIDs = imageContainers.map { it.id }.toSet()
|
|
2063
2235
|
val id = imageContainerIDPool.firstOrNull { it !in usedIDs } ?: imageContainerIDPool[0]
|
|
2064
2236
|
val container =
|
|
2065
|
-
|
|
2237
|
+
ImgContainer(id = id, x = x, y = y, width = width, height = height, bmpData = bmpData)
|
|
2066
2238
|
imageContainers.add(container)
|
|
2067
2239
|
return container
|
|
2068
2240
|
}
|
|
2069
2241
|
|
|
2070
2242
|
private fun addTextContainer(
|
|
2071
|
-
|
|
2072
|
-
|
|
2073
|
-
|
|
2074
|
-
|
|
2075
|
-
|
|
2076
|
-
|
|
2077
|
-
|
|
2078
|
-
|
|
2079
|
-
|
|
2243
|
+
x: Int,
|
|
2244
|
+
y: Int,
|
|
2245
|
+
width: Int,
|
|
2246
|
+
height: Int,
|
|
2247
|
+
content: String,
|
|
2248
|
+
borderWidth: Int,
|
|
2249
|
+
borderColor: Int,
|
|
2250
|
+
borderRadius: Int,
|
|
2251
|
+
paddingLength: Int
|
|
2080
2252
|
): TextContainer {
|
|
2081
2253
|
// Evict the oldest container when at capacity, freeing its ID for reuse.
|
|
2082
2254
|
if (textContainers.size >= textContainerIDPool.size) {
|
|
@@ -2087,18 +2259,18 @@ class G2 : SGCManager() {
|
|
|
2087
2259
|
val usedIDs = textContainers.map { it.id }.toSet()
|
|
2088
2260
|
val id = textContainerIDPool.firstOrNull { it !in usedIDs } ?: textContainerIDPool[0]
|
|
2089
2261
|
val container =
|
|
2090
|
-
|
|
2091
|
-
|
|
2092
|
-
|
|
2093
|
-
|
|
2094
|
-
|
|
2095
|
-
|
|
2096
|
-
|
|
2097
|
-
|
|
2098
|
-
|
|
2099
|
-
|
|
2100
|
-
|
|
2101
|
-
|
|
2262
|
+
TextContainer(
|
|
2263
|
+
id = id,
|
|
2264
|
+
x = x,
|
|
2265
|
+
y = y,
|
|
2266
|
+
width = width,
|
|
2267
|
+
height = height,
|
|
2268
|
+
content = content,
|
|
2269
|
+
borderWidth = borderWidth,
|
|
2270
|
+
borderColor = borderColor,
|
|
2271
|
+
borderRadius = borderRadius,
|
|
2272
|
+
paddingLength = paddingLength
|
|
2273
|
+
)
|
|
2102
2274
|
textContainers.add(container)
|
|
2103
2275
|
return container
|
|
2104
2276
|
}
|
|
@@ -2107,95 +2279,249 @@ class G2 : SGCManager() {
|
|
|
2107
2279
|
* Shutdown and rebuild everything, re-sending all data to the glasses.
|
|
2108
2280
|
*
|
|
2109
2281
|
* Suspends until the rebuild (shutdown → create → image/text re-send) has been issued, so
|
|
2110
|
-
* callers no longer race a detached coroutine. Always invoke
|
|
2282
|
+
* callers no longer race a detached coroutine. Always invoke on [displayScope].
|
|
2111
2283
|
*/
|
|
2112
2284
|
private suspend fun rebuildPage() {
|
|
2113
2285
|
val msg = EvenHubProto.shutdownMessage()
|
|
2114
2286
|
sendEvenHubCommand(msg)
|
|
2115
2287
|
pageCreated = false
|
|
2116
|
-
|
|
2288
|
+
// we will automatically rebuild state when we detect the glasses shutdown:
|
|
2289
|
+
delay(300) // 300ms to settle
|
|
2290
|
+
// rebuildState()
|
|
2117
2291
|
}
|
|
2118
2292
|
|
|
2119
2293
|
/**
|
|
2120
|
-
* Re-creates the containers and sends all images
|
|
2294
|
+
* Re-creates the containers and re-sends all images to the glasses.
|
|
2121
2295
|
*
|
|
2122
2296
|
* Runs inline (suspending) rather than launching a detached coroutine, so the page is fully
|
|
2123
|
-
* rebuilt before the caller continues. Always invoke
|
|
2297
|
+
* rebuilt before the caller continues. Always invoke on [displayScope].
|
|
2124
2298
|
*/
|
|
2125
2299
|
private suspend fun rebuildState() {
|
|
2126
2300
|
Bridge.log("G2: rebuildState()")
|
|
2127
|
-
// recreate the containers:
|
|
2301
|
+
// recreate the containers (sets pageCreated = true; embeds text content directly):
|
|
2128
2302
|
createPageWithContainers()
|
|
2129
|
-
|
|
2130
2303
|
delay(300) // 300ms to settle
|
|
2131
2304
|
|
|
2132
|
-
//
|
|
2133
|
-
|
|
2134
|
-
|
|
2135
|
-
|
|
2136
|
-
|
|
2137
|
-
|
|
2138
|
-
|
|
2139
|
-
|
|
2140
|
-
|
|
2141
|
-
|
|
2142
|
-
|
|
2143
|
-
|
|
2144
|
-
|
|
2145
|
-
// contentLength = container.content.toByteArray(Charsets.UTF_8).size,
|
|
2146
|
-
// content = container.content
|
|
2147
|
-
// )
|
|
2148
|
-
// sendEvenHubCommand(textMsg)
|
|
2149
|
-
// delay(100)
|
|
2150
|
-
// }
|
|
2305
|
+
// Mark every image container dirty and let the reconcile loop re-send them, one at a time.
|
|
2306
|
+
// Doing the sends here directly is what used to race a concurrent displayBitmap and clobber
|
|
2307
|
+
// the image ACK; routing through the dirty flag keeps a single sender (the reconcile loop).
|
|
2308
|
+
// Text needs no resend here: createPageWithContainers already embeds each container's content.
|
|
2309
|
+
for (i in imageContainers.indices) {
|
|
2310
|
+
if (imageContainers[i].bmpData.isNotEmpty()) {
|
|
2311
|
+
Bridge.log(
|
|
2312
|
+
"G2: rebuildState() - marking container ${imageContainers[i].id} dirty (${imageContainers[i].bmpData.size} bytes)"
|
|
2313
|
+
)
|
|
2314
|
+
imageContainers[i].dirty = true
|
|
2315
|
+
}
|
|
2316
|
+
}
|
|
2317
|
+
signalDisplayDirty()
|
|
2151
2318
|
|
|
2152
2319
|
delay(300) // 300ms to settle
|
|
2153
2320
|
restartMicIfAlreadyEnabled()
|
|
2154
2321
|
}
|
|
2155
2322
|
|
|
2323
|
+
/**
|
|
2324
|
+
* Single coalesced recovery: rebuild the page + re-arm the mic from intent, never stacking
|
|
2325
|
+
* rebuilds (the firmware spams systemExit/dashboard-close ~1×/sec). Skips when the page is
|
|
2326
|
+
* already alive and the mic matches intent; otherwise one rebuild in flight, one per
|
|
2327
|
+
* RECOVERY_DEBOUNCE_MS. See iOS G2.swift.
|
|
2328
|
+
*/
|
|
2329
|
+
private fun recoverPageAndMic(reason: String) {
|
|
2330
|
+
val now = System.currentTimeMillis()
|
|
2331
|
+
// Page alive and mic matches intent → phantom firmware event, nothing to recover.
|
|
2332
|
+
val micIntent = DeviceStore.get("glasses", "micEnabled") as? Boolean ?: false
|
|
2333
|
+
if (pageCreated && evenHubMicActive == micIntent) {
|
|
2334
|
+
return
|
|
2335
|
+
}
|
|
2336
|
+
if (recoveryInFlight) {
|
|
2337
|
+
// Bridge.log("G2: recover($reason) skipped — already in flight")
|
|
2338
|
+
return
|
|
2339
|
+
}
|
|
2340
|
+
if (now - lastRecoveryRebuildMs < RECOVERY_DEBOUNCE_MS) {
|
|
2341
|
+
// Bridge.log("G2: recover($reason) skipped — debounced")
|
|
2342
|
+
return
|
|
2343
|
+
}
|
|
2344
|
+
recoveryInFlight = true
|
|
2345
|
+
lastRecoveryRebuildMs = now
|
|
2346
|
+
Bridge.log("G2: recover($reason) — rebuilding EvenHub page")
|
|
2347
|
+
displayScope.launch {
|
|
2348
|
+
rebuildState()
|
|
2349
|
+
DeviceManager.getInstance().sendCurrentState()
|
|
2350
|
+
recoveryInFlight = false
|
|
2351
|
+
}
|
|
2352
|
+
}
|
|
2353
|
+
|
|
2354
|
+
/**
|
|
2355
|
+
* Push pending display state to the glasses: dirty text containers first (one updateText each,
|
|
2356
|
+
* with a redundant resend per [TextContainer.pendingSends]), then dirty image containers one at a
|
|
2357
|
+
* time. This awaits each [sendImageData] in turn, so image sends never overlap and the single
|
|
2358
|
+
* image-ACK slot is never clobbered. A failed image send leaves the container dirty for the next
|
|
2359
|
+
* cycle; if its bytes changed mid-send, the flag stays set so the newer image is sent next.
|
|
2360
|
+
*/
|
|
2361
|
+
private suspend fun reconcileDisplay() {
|
|
2362
|
+
// Page is dead but content is waiting (e.g. captions kept arriving while we were suspended
|
|
2363
|
+
// and the firmware tore the session down). Rebuild the page ONCE here — the reconcile loop
|
|
2364
|
+
// is coalesced, so a burst of buffered sendText calls collapses into a single rebuild
|
|
2365
|
+
// instead of one shutdown/rebuild per caption. rebuildState() recreates the page, re-pushes
|
|
2366
|
+
// the current text/image, and re-arms the mic iff intent says so. Skip while the native
|
|
2367
|
+
// dashboard owns the screen.
|
|
2368
|
+
if (!pageCreated) {
|
|
2369
|
+
val useNativeDashboard = DeviceStore.get("bluetooth", "use_native_dashboard") as? Boolean ?: false
|
|
2370
|
+
// Only resurrect a dead page for MEANINGFUL content. A clearDisplay (blank " ") on an
|
|
2371
|
+
// already-dead page has nothing to show, so don't rebuild just to render blankness —
|
|
2372
|
+
// that would let a clear burst churn the page back up pointlessly.
|
|
2373
|
+
val hasPendingText = textContainers.any { it.pendingSends > 0 && it.content.isNotBlank() }
|
|
2374
|
+
val hasPendingImage = imageContainers.any { it.dirty && it.bmpData.isNotEmpty() }
|
|
2375
|
+
if ((hasPendingText || hasPendingImage) && !(useNativeDashboard && dashboardShowing > 0)) {
|
|
2376
|
+
Bridge.log("G2: reconcileDisplay() - page down with pending content, rebuilding once")
|
|
2377
|
+
rebuildState()
|
|
2378
|
+
}
|
|
2379
|
+
return
|
|
2380
|
+
}
|
|
2381
|
+
|
|
2382
|
+
// Text: synchronous, no ACK. Send one update per container with pending sends and decrement.
|
|
2383
|
+
for (i in textContainers.indices) {
|
|
2384
|
+
if (textContainers[i].pendingSends <= 0) continue
|
|
2385
|
+
val container = textContainers[i]
|
|
2386
|
+
val msg =
|
|
2387
|
+
EvenHubProto.updateTextMessage(
|
|
2388
|
+
containerID = container.id,
|
|
2389
|
+
contentOffset = 0,
|
|
2390
|
+
contentLength = container.content.toByteArray(Charsets.UTF_8).size,
|
|
2391
|
+
content = container.content
|
|
2392
|
+
)
|
|
2393
|
+
sendEvenHubCommand(msg)
|
|
2394
|
+
textContainers[i].pendingSends -= 1
|
|
2395
|
+
}
|
|
2396
|
+
|
|
2397
|
+
// Images: ACK-gated, exactly one in flight. Cap iterations defensively so a container that
|
|
2398
|
+
// keeps being re-dirtied mid-send can't spin this pass forever (next tick picks it up).
|
|
2399
|
+
var guardCount = 0
|
|
2400
|
+
while (pageCreated && guardCount < imageContainerIDPool.size) {
|
|
2401
|
+
val i = imageContainers.indexOfFirst { it.dirty }
|
|
2402
|
+
if (i < 0) break
|
|
2403
|
+
guardCount += 1
|
|
2404
|
+
val container = imageContainers[i]
|
|
2405
|
+
val sentBytes = container.bmpData
|
|
2406
|
+
// Empty + dirty means "just cleared": the firmware still shows the old image, so push an
|
|
2407
|
+
// all-black frame sized to the container to overwrite it on-glass (the page stays up — no
|
|
2408
|
+
// teardown, no mic churn). A container only reaches here when dirty, and clearDisplay is
|
|
2409
|
+
// the sole source of an empty-but-dirty container, so this fires exactly on a clear.
|
|
2410
|
+
if (sentBytes.isEmpty()) {
|
|
2411
|
+
val blank = blankBmp(container.width, container.height)
|
|
2412
|
+
if (blank != null) {
|
|
2413
|
+
sendImageData(container.id, container.name, blank)
|
|
2414
|
+
}
|
|
2415
|
+
// Only settle the flag if it's still empty — a displayBitmap during the await would
|
|
2416
|
+
// have set new bytes, so leave it dirty for the next pass to send the real image.
|
|
2417
|
+
val jj = imageContainers.indexOfFirst { it.id == container.id }
|
|
2418
|
+
if (jj >= 0 && imageContainers[jj].bmpData.isEmpty()) {
|
|
2419
|
+
imageContainers[jj].dirty = false
|
|
2420
|
+
}
|
|
2421
|
+
continue
|
|
2422
|
+
}
|
|
2423
|
+
sendImageData(container.id, container.name, sentBytes)
|
|
2424
|
+
// Re-find by id: the list may have shifted (eviction) during the await.
|
|
2425
|
+
val j = imageContainers.indexOfFirst { it.id == container.id }
|
|
2426
|
+
if (j >= 0 && imageContainers[j].bmpData.contentEquals(sentBytes)) {
|
|
2427
|
+
imageContainers[j].dirty = false
|
|
2428
|
+
}
|
|
2429
|
+
}
|
|
2430
|
+
}
|
|
2431
|
+
|
|
2156
2432
|
/**
|
|
2157
2433
|
* Send a bitmap to an image container as fragmented updateImageRawData packets.
|
|
2158
2434
|
*
|
|
2159
|
-
*
|
|
2160
|
-
*
|
|
2161
|
-
*
|
|
2435
|
+
* One MapSessionId identifies the whole image transfer (constant across its fragments); the
|
|
2436
|
+
* glasses ACK EACH fragment with an ImgResCmd ErrorCode (4=success, 5=failed). Each fragment is
|
|
2437
|
+
* gated on its own ACK — correlated by (session, fragmentIndex) — before the next is sent, so
|
|
2438
|
+
* the ACK itself paces the transfer (no fixed inter-fragment delay). A `failed` ACK OR no ACK
|
|
2439
|
+
* within [IMG_ACK_TIMEOUT_MS] abandons the attempt and re-sends the entire image (fresh session
|
|
2440
|
+
* id) up to [IMG_MAX_ATTEMPTS] times. On exhausting all attempts it logs a warning and returns
|
|
2441
|
+
* (best-effort — callers are unaffected).
|
|
2442
|
+
*
|
|
2443
|
+
* Suspends until the image is acknowledged (or all attempts fail), so the 300ms settle in
|
|
2444
|
+
* [rebuildState] only runs once the transfer has fully resolved.
|
|
2162
2445
|
*/
|
|
2163
2446
|
private suspend fun sendImageData(
|
|
2164
|
-
|
|
2165
|
-
|
|
2166
|
-
|
|
2447
|
+
containerID: Int,
|
|
2448
|
+
containerName: String,
|
|
2449
|
+
bmpData: ByteArray
|
|
2167
2450
|
) {
|
|
2168
2451
|
val fragmentSize = 4096
|
|
2169
|
-
imageSessionCounter++
|
|
2170
|
-
val sessionId = imageSessionCounter
|
|
2171
2452
|
val totalSize = bmpData.size
|
|
2172
|
-
|
|
2173
|
-
var offset = 0
|
|
2174
|
-
|
|
2175
|
-
Bridge.log("G2: sendImageData($containerName) - $fragmentIndex fragments, ${bmpData.size} bytes")
|
|
2453
|
+
val fragmentCount = (bmpData.size + fragmentSize - 1) / fragmentSize
|
|
2176
2454
|
|
|
2177
|
-
|
|
2178
|
-
|
|
2179
|
-
|
|
2455
|
+
// skip if the image is empty:
|
|
2456
|
+
if (bmpData.size == 0) {
|
|
2457
|
+
return
|
|
2458
|
+
}
|
|
2180
2459
|
|
|
2181
|
-
|
|
2460
|
+
// Bridge.log("G2: sendImageData($containerName) - $fragmentCount fragments, ${bmpData.size} bytes")
|
|
2461
|
+
|
|
2462
|
+
for (attempt in 1..IMG_MAX_ATTEMPTS) {
|
|
2463
|
+
// One session id per WHOLE image transfer (per attempt). The glasses key their
|
|
2464
|
+
// reassembly buffer on MapSessionId, so every fragment reuses it with an incrementing
|
|
2465
|
+
// MapFragmentIndex. A retry uses a fresh session so a stale ACK from a prior attempt
|
|
2466
|
+
// can't match.
|
|
2467
|
+
imageSessionCounter = (imageSessionCounter + 1) % 256
|
|
2468
|
+
val sessionId = imageSessionCounter
|
|
2469
|
+
|
|
2470
|
+
var fragmentIndex = 0
|
|
2471
|
+
var offset = 0
|
|
2472
|
+
var transferOk = true
|
|
2473
|
+
// if (attempt > 1) {
|
|
2474
|
+
// Bridge.log("G2: sendImageData($containerName) - attempt $attempt starting")
|
|
2475
|
+
// }
|
|
2476
|
+
while (offset < bmpData.size) {
|
|
2477
|
+
val end = minOf(offset + fragmentSize, bmpData.size)
|
|
2478
|
+
val fragment = bmpData.copyOfRange(offset, end)
|
|
2479
|
+
|
|
2480
|
+
val ack = CompletableDeferred<Boolean>()
|
|
2481
|
+
pendingImgAckSession = sessionId
|
|
2482
|
+
pendingImgAckFragment = fragmentIndex
|
|
2483
|
+
pendingImgAck = ack
|
|
2484
|
+
|
|
2485
|
+
Bridge.log("G2: img_sen: session=$sessionId fragment=$fragmentIndex")
|
|
2486
|
+
|
|
2487
|
+
val msg =
|
|
2182
2488
|
EvenHubProto.updateImageRawDataMessage(
|
|
2183
|
-
|
|
2184
|
-
|
|
2185
|
-
|
|
2186
|
-
|
|
2187
|
-
|
|
2188
|
-
|
|
2189
|
-
|
|
2190
|
-
|
|
2489
|
+
containerID = containerID,
|
|
2490
|
+
containerName = containerName,
|
|
2491
|
+
mapSessionId = sessionId,
|
|
2492
|
+
mapTotalSize = totalSize,
|
|
2493
|
+
compressMode = 0,
|
|
2494
|
+
mapFragmentIndex = fragmentIndex,
|
|
2495
|
+
mapFragmentPacketSize = fragment.size,
|
|
2496
|
+
mapRawData = fragment
|
|
2191
2497
|
)
|
|
2192
|
-
|
|
2193
|
-
|
|
2498
|
+
sendEvenHubCommand(msg)
|
|
2499
|
+
|
|
2500
|
+
// Gate on THIS fragment's ACK before sending the next (the ACK provides pacing).
|
|
2501
|
+
// null=timeout, false=img_failed → abandon the attempt and retry the whole image.
|
|
2502
|
+
val ok = withTimeoutOrNull(IMG_ACK_TIMEOUT_MS) { ack.await() }
|
|
2503
|
+
if (pendingImgAck === ack) {
|
|
2504
|
+
pendingImgAck = null
|
|
2505
|
+
pendingImgAckSession = null
|
|
2506
|
+
pendingImgAckFragment = null
|
|
2507
|
+
}
|
|
2508
|
+
if (ok != true) {
|
|
2509
|
+
val reason = if (ok == null) "timeout" else "img_failed"
|
|
2510
|
+
// Bridge.log("G2: sendImageData($containerName) - attempt $attempt fragment $fragmentIndex failed ($reason)")
|
|
2511
|
+
transferOk = false
|
|
2512
|
+
break
|
|
2513
|
+
}
|
|
2514
|
+
fragmentIndex++
|
|
2515
|
+
offset = end
|
|
2516
|
+
}
|
|
2194
2517
|
|
|
2195
|
-
|
|
2196
|
-
|
|
2197
|
-
|
|
2518
|
+
if (transferOk) {
|
|
2519
|
+
// Bridge.log("G2: img_sen: container=$containerName - success=true")
|
|
2520
|
+
return
|
|
2521
|
+
}
|
|
2198
2522
|
}
|
|
2523
|
+
|
|
2524
|
+
Bridge.log("G2: img_sen: sendImageData($containerName) - failed after $IMG_MAX_ATTEMPTS attempts")
|
|
2199
2525
|
}
|
|
2200
2526
|
|
|
2201
2527
|
/// Bring the Even Realities dashboard (the OS-level home/idle screen) to
|
|
@@ -2203,10 +2529,14 @@ class G2 : SGCManager() {
|
|
|
2203
2529
|
/// The glasses fall back to the dashboard automatically when no page is up.
|
|
2204
2530
|
override fun showDashboard() {
|
|
2205
2531
|
Bridge.log("G2: showDashboard()")
|
|
2206
|
-
|
|
2532
|
+
// Dashboard is open: a 0/1 flag (the old +=2/-=1 depth dance drifted >0 and wedged the
|
|
2533
|
+
// mic). dashboardOpening latches so the open-confirm 08011A00 doesn't trigger recovery.
|
|
2534
|
+
dashboardShowing = 1
|
|
2535
|
+
dashboardOpening = true
|
|
2207
2536
|
val msg = EvenHubProto.shutdownMessage()
|
|
2208
2537
|
sendEvenHubCommand(msg)
|
|
2209
2538
|
pageCreated = false
|
|
2539
|
+
evenHubMicActive = false // dashboard takes EvenHub focus; firmware kills the mic
|
|
2210
2540
|
currentBitmapBase64 = ""
|
|
2211
2541
|
mainHandler.postDelayed({
|
|
2212
2542
|
// activate the dashboard by setting depth to the current setting:
|
|
@@ -2238,12 +2568,12 @@ class G2 : SGCManager() {
|
|
|
2238
2568
|
override fun setDashboardMenu(items: List<Map<String, Any>>) {
|
|
2239
2569
|
Bridge.log("G2: setDashboardMenu -- items: $items")
|
|
2240
2570
|
val menuItems =
|
|
2241
|
-
|
|
2242
|
-
|
|
2243
|
-
|
|
2244
|
-
|
|
2245
|
-
|
|
2246
|
-
|
|
2571
|
+
items.mapNotNull { dict ->
|
|
2572
|
+
val name = dict["name"] as? String ?: return@mapNotNull null
|
|
2573
|
+
val packageName = dict["packageName"] as? String ?: return@mapNotNull null
|
|
2574
|
+
val running = dict["running"] as? Boolean ?: false
|
|
2575
|
+
MenuProto.MenuItem(packageName, name, running)
|
|
2576
|
+
}
|
|
2247
2577
|
dashboardMenuItems.clear()
|
|
2248
2578
|
dashboardMenuItems.addAll(menuItems)
|
|
2249
2579
|
Bridge.log("G2: setDashboardMenu -- sending ${menuItems.size} items")
|
|
@@ -2267,11 +2597,11 @@ class G2 : SGCManager() {
|
|
|
2267
2597
|
Bridge.log("G2: sendCalendarEvents -- ${events.size} events")
|
|
2268
2598
|
if (events.isEmpty()) {
|
|
2269
2599
|
sendDashboardCommand(
|
|
2270
|
-
|
|
2271
|
-
|
|
2272
|
-
|
|
2273
|
-
|
|
2274
|
-
|
|
2600
|
+
CalendarProto.calendarClear(
|
|
2601
|
+
magicRandom = sendManager.nextMagicRandom(),
|
|
2602
|
+
packageId = 1,
|
|
2603
|
+
scheduleAuthority = 1
|
|
2604
|
+
)
|
|
2275
2605
|
)
|
|
2276
2606
|
return
|
|
2277
2607
|
}
|
|
@@ -2287,18 +2617,18 @@ class G2 : SGCManager() {
|
|
|
2287
2617
|
val location = ev["location"] as? String
|
|
2288
2618
|
|
|
2289
2619
|
val payload =
|
|
2290
|
-
|
|
2291
|
-
|
|
2292
|
-
|
|
2293
|
-
|
|
2294
|
-
|
|
2295
|
-
|
|
2296
|
-
|
|
2297
|
-
|
|
2298
|
-
|
|
2299
|
-
|
|
2300
|
-
|
|
2301
|
-
|
|
2620
|
+
CalendarProto.calendarPush(
|
|
2621
|
+
magicRandom = sendManager.nextMagicRandom(),
|
|
2622
|
+
packageId = 1,
|
|
2623
|
+
scheduleId = i + 1,
|
|
2624
|
+
title = title,
|
|
2625
|
+
location = location,
|
|
2626
|
+
time = time,
|
|
2627
|
+
endTimestamp = (endTs + tzSec).toInt(),
|
|
2628
|
+
scheduleAuthority = 1,
|
|
2629
|
+
scheduleTotal = total,
|
|
2630
|
+
scheduleNum = i
|
|
2631
|
+
)
|
|
2302
2632
|
sendDashboardCommand(payload)
|
|
2303
2633
|
}
|
|
2304
2634
|
}
|
|
@@ -2315,27 +2645,27 @@ class G2 : SGCManager() {
|
|
|
2315
2645
|
override suspend fun showNotificationsPanel() {
|
|
2316
2646
|
Bridge.log("G2: showNotificationsPanel()")
|
|
2317
2647
|
val enterPayload = EvenAIProto.aiCtrl(
|
|
2318
|
-
|
|
2319
|
-
|
|
2648
|
+
magicRandom = sendManager.nextMagicRandom(),
|
|
2649
|
+
status = 2 // EVEN_AI_ENTER
|
|
2320
2650
|
)
|
|
2321
2651
|
sendEvenAICommand(enterPayload)
|
|
2322
2652
|
|
|
2323
2653
|
delay(400)
|
|
2324
2654
|
val askPayload = EvenAIProto.aiAsk(
|
|
2325
|
-
|
|
2326
|
-
|
|
2327
|
-
|
|
2655
|
+
magicRandom = sendManager.nextMagicRandom(),
|
|
2656
|
+
text = " ",
|
|
2657
|
+
streamEnable = 0
|
|
2328
2658
|
)
|
|
2329
2659
|
sendEvenAICommand(askPayload)
|
|
2330
2660
|
|
|
2331
2661
|
delay(400)
|
|
2332
2662
|
val skillPayload = EvenAIProto.triggerSkill(
|
|
2333
|
-
|
|
2334
|
-
|
|
2335
|
-
|
|
2336
|
-
|
|
2337
|
-
|
|
2338
|
-
|
|
2663
|
+
magicRandom = sendManager.nextMagicRandom(),
|
|
2664
|
+
skillId = 3, // NOTIFICATION
|
|
2665
|
+
skillParam = 1, // show
|
|
2666
|
+
text = " ",
|
|
2667
|
+
streamEnable = 1,
|
|
2668
|
+
fTextEnd = 1
|
|
2339
2669
|
)
|
|
2340
2670
|
sendEvenAICommand(skillPayload)
|
|
2341
2671
|
}
|
|
@@ -2343,11 +2673,11 @@ class G2 : SGCManager() {
|
|
|
2343
2673
|
override fun setBrightness(level: Int, autoMode: Boolean) {
|
|
2344
2674
|
Bridge.log("G2: setBrightness($level, auto=$autoMode)")
|
|
2345
2675
|
val msg =
|
|
2346
|
-
|
|
2347
|
-
|
|
2348
|
-
|
|
2349
|
-
|
|
2350
|
-
|
|
2676
|
+
G2SettingProto.setBrightness(
|
|
2677
|
+
magicRandom = sendManager.nextMagicRandom(),
|
|
2678
|
+
level = level,
|
|
2679
|
+
autoAdjust = autoMode
|
|
2680
|
+
)
|
|
2351
2681
|
sendG2SettingCommand(msg)
|
|
2352
2682
|
}
|
|
2353
2683
|
|
|
@@ -2376,7 +2706,7 @@ class G2 : SGCManager() {
|
|
|
2376
2706
|
)
|
|
2377
2707
|
}
|
|
2378
2708
|
}
|
|
2379
|
-
|
|
2709
|
+
|
|
2380
2710
|
|
|
2381
2711
|
// iterate all image containers, remove any entries with duplicate ids or empty data,
|
|
2382
2712
|
// and ensure the ids are still in the imageContainerIDPool:
|
|
@@ -2395,95 +2725,73 @@ class G2 : SGCManager() {
|
|
|
2395
2725
|
|
|
2396
2726
|
// Build the page's image containers from the live tracked list.
|
|
2397
2727
|
val imageContainerProps: List<ByteArray> =
|
|
2398
|
-
|
|
2399
|
-
|
|
2400
|
-
|
|
2401
|
-
|
|
2402
|
-
|
|
2403
|
-
|
|
2404
|
-
|
|
2405
|
-
|
|
2406
|
-
|
|
2407
|
-
|
|
2728
|
+
imageContainers.map { c ->
|
|
2729
|
+
EvenHubProto.imageContainerProperty(
|
|
2730
|
+
x = c.x,
|
|
2731
|
+
y = c.y,
|
|
2732
|
+
width = c.width,
|
|
2733
|
+
height = c.height,
|
|
2734
|
+
containerID = c.id,
|
|
2735
|
+
containerName = c.name
|
|
2736
|
+
)
|
|
2737
|
+
}
|
|
2408
2738
|
|
|
2409
2739
|
val msg: ByteArray
|
|
2410
2740
|
if (!pageCreated) {
|
|
2411
2741
|
Bridge.log("G2: using createPageMessage (first time)")
|
|
2412
2742
|
msg =
|
|
2413
|
-
|
|
2414
|
-
|
|
2415
|
-
|
|
2416
|
-
|
|
2417
|
-
|
|
2418
|
-
|
|
2743
|
+
EvenHubProto.createPageMessage(
|
|
2744
|
+
textContainers = textContainerProps,
|
|
2745
|
+
imageContainers = imageContainerProps,
|
|
2746
|
+
magicRandom = sendManager.nextMagicRandom(),
|
|
2747
|
+
appId = activeMenuAppId
|
|
2748
|
+
)
|
|
2419
2749
|
} else {
|
|
2420
2750
|
Bridge.log("G2: using rebuildPageMessage")
|
|
2421
2751
|
msg =
|
|
2422
|
-
|
|
2423
|
-
|
|
2424
|
-
|
|
2425
|
-
|
|
2426
|
-
|
|
2427
|
-
|
|
2752
|
+
EvenHubProto.rebuildPageMessage(
|
|
2753
|
+
textContainers = textContainerProps,
|
|
2754
|
+
imageContainers = imageContainerProps,
|
|
2755
|
+
magicRandom = sendManager.nextMagicRandom(),
|
|
2756
|
+
appId = activeMenuAppId
|
|
2757
|
+
)
|
|
2428
2758
|
}
|
|
2429
2759
|
sendEvenHubCommand(msg)
|
|
2430
2760
|
pageCreated = true
|
|
2431
2761
|
}
|
|
2432
2762
|
|
|
2433
|
-
@Synchronized
|
|
2434
|
-
private fun queueEvenHubCommand(payload: ByteArray) {
|
|
2435
|
-
pendingTextMsg = payload
|
|
2436
|
-
}
|
|
2437
|
-
|
|
2438
|
-
@Synchronized
|
|
2439
|
-
private fun drainEvenHubQueue() {
|
|
2440
|
-
val msg = pendingTextMsg
|
|
2441
|
-
pendingTextMsg = null
|
|
2442
|
-
val toSend: ByteArray? = if (msg != null) {
|
|
2443
|
-
lastEvenHubMsg = msg
|
|
2444
|
-
lastEvenHubResendsRemaining = EVEN_HUB_RESEND_COUNT
|
|
2445
|
-
msg
|
|
2446
|
-
} else if (lastEvenHubResendsRemaining > 0 && lastEvenHubMsg != null) {
|
|
2447
|
-
lastEvenHubResendsRemaining -= 1
|
|
2448
|
-
lastEvenHubMsg
|
|
2449
|
-
} else {
|
|
2450
|
-
null
|
|
2451
|
-
}
|
|
2452
|
-
toSend?.let { sendEvenHubCommand(it) }
|
|
2453
|
-
}
|
|
2454
|
-
|
|
2455
2763
|
// ---------- Bitmap Conversion ----------
|
|
2456
2764
|
|
|
2457
2765
|
private fun convertToG2Bmp(
|
|
2458
|
-
|
|
2459
|
-
|
|
2460
|
-
|
|
2766
|
+
data: ByteArray,
|
|
2767
|
+
containerWidth: Int,
|
|
2768
|
+
containerHeight: Int
|
|
2461
2769
|
): ByteArray? {
|
|
2462
2770
|
val srcBitmap =
|
|
2463
|
-
|
|
2464
|
-
|
|
2465
|
-
|
|
2466
|
-
|
|
2467
|
-
|
|
2771
|
+
BitmapFactory.decodeByteArray(data, 0, data.size)
|
|
2772
|
+
?: run {
|
|
2773
|
+
Bridge.log("G2: convertToG2Bmp - could not decode image")
|
|
2774
|
+
return null
|
|
2775
|
+
}
|
|
2468
2776
|
|
|
2469
2777
|
val srcWidth = srcBitmap.width
|
|
2470
2778
|
val srcHeight = srcBitmap.height
|
|
2471
2779
|
|
|
2472
2780
|
// Scale to fit within container (maintain aspect ratio)
|
|
2473
2781
|
val scale =
|
|
2474
|
-
|
|
2782
|
+
minOf(containerWidth.toDouble() / srcWidth, containerHeight.toDouble() / srcHeight)
|
|
2475
2783
|
val scaledW = maxOf(1, (srcWidth * scale).toInt())
|
|
2476
2784
|
val scaledH = maxOf(1, (srcHeight * scale).toInt())
|
|
2477
2785
|
val offsetX = (containerWidth - scaledW) / 2
|
|
2478
2786
|
val offsetY = (containerHeight - scaledH) / 2
|
|
2479
2787
|
|
|
2480
|
-
Bridge.log(
|
|
2481
|
-
|
|
2482
|
-
)
|
|
2788
|
+
// Bridge.log(
|
|
2789
|
+
// "G2: convertToG2Bmp - input ${srcWidth}x${srcHeight} → scaled ${scaledW}x${scaledH} in ${containerWidth}x${containerHeight}"
|
|
2790
|
+
// )
|
|
2483
2791
|
|
|
2484
2792
|
// Render to container-sized bitmap with black background
|
|
2485
2793
|
val destBitmap =
|
|
2486
|
-
|
|
2794
|
+
Bitmap.createBitmap(containerWidth, containerHeight, Bitmap.Config.ARGB_8888)
|
|
2487
2795
|
val canvas = Canvas(destBitmap)
|
|
2488
2796
|
canvas.drawColor(Color.BLACK)
|
|
2489
2797
|
|
|
@@ -2511,6 +2819,17 @@ class G2 : SGCManager() {
|
|
|
2511
2819
|
return build4BitBmp(grayscalePixels, containerWidth, containerHeight)
|
|
2512
2820
|
}
|
|
2513
2821
|
|
|
2822
|
+
/**
|
|
2823
|
+
* Build an all-black BMP sized to a container. Sent to overwrite (and thus visually clear) an
|
|
2824
|
+
* image container without tearing the page down — on the green monochrome display, pixel 0 is
|
|
2825
|
+
* unlit, so an all-zero frame reads as blank. Used by the reconcile loop to clear a bitmap.
|
|
2826
|
+
*/
|
|
2827
|
+
private fun blankBmp(width: Int, height: Int): ByteArray? {
|
|
2828
|
+
if (width <= 0 || height <= 0) return null
|
|
2829
|
+
val zeros = ByteArray(width * height) // all-zero 8-bit grayscale = black
|
|
2830
|
+
return build4BitBmp(zeros, width, height)
|
|
2831
|
+
}
|
|
2832
|
+
|
|
2514
2833
|
private fun build4BitBmp(grayscalePixels: ByteArray, width: Int, height: Int): ByteArray? {
|
|
2515
2834
|
// 4-bit: 2 pixels per byte, rows padded to 4-byte boundary
|
|
2516
2835
|
val bytesPerRow4bit = (width + 1) / 2
|
|
@@ -2605,28 +2924,43 @@ class G2 : SGCManager() {
|
|
|
2605
2924
|
}
|
|
2606
2925
|
|
|
2607
2926
|
fun restartMic() {
|
|
2608
|
-
//
|
|
2927
|
+
// Intent is "mic on". The mic only exists inside a live EvenHub page, so we toggle it
|
|
2928
|
+
// off then back on (the firmware needs the off→on edge to re-arm).
|
|
2609
2929
|
DeviceStore.apply("glasses", "micEnabled", true)
|
|
2930
|
+
evenHubMicActive = false
|
|
2610
2931
|
val msg = EvenHubProto.audioControlMessage(false)
|
|
2611
2932
|
sendEvenHubCommand(msg)
|
|
2612
2933
|
mainHandler.postDelayed({
|
|
2613
2934
|
val useNativeDashboard = DeviceStore.get("bluetooth", "use_native_dashboard") as? Boolean ?: false
|
|
2614
2935
|
// Bridge.log("G2: setMicEnabled - useNativeDashboard=$useNativeDashboard, dashboardShowing=$dashboardShowing")
|
|
2936
|
+
// Dashboard owns the screen + session right now — don't arm the mic into a page the
|
|
2937
|
+
// dashboard has taken over; recovery re-arms on dashboard close.
|
|
2615
2938
|
if (useNativeDashboard && dashboardShowing > 0) {
|
|
2616
2939
|
return@postDelayed
|
|
2617
2940
|
}
|
|
2941
|
+
// Never send audioControl(enable=true) without a live page — no page means no mic.
|
|
2942
|
+
// Rebuild first, which itself re-arms the mic at the end (intent is on), so we're done.
|
|
2618
2943
|
if (!pageCreated) {
|
|
2619
|
-
|
|
2944
|
+
displayScope.launch {
|
|
2945
|
+
rebuildState()
|
|
2946
|
+
DeviceManager.getInstance().sendCurrentState()
|
|
2947
|
+
}
|
|
2948
|
+
return@postDelayed
|
|
2620
2949
|
}
|
|
2621
2950
|
val msg = EvenHubProto.audioControlMessage(true)
|
|
2622
2951
|
sendEvenHubCommand(msg)
|
|
2952
|
+
evenHubMicActive = true
|
|
2623
2953
|
}, 500)
|
|
2624
2954
|
}
|
|
2625
2955
|
|
|
2626
2956
|
override fun setMicEnabled(enabled: Boolean) {
|
|
2627
2957
|
Bridge.log("G2: setMicEnabled($enabled)")
|
|
2958
|
+
if (enabled && !pageCreated) {
|
|
2959
|
+
restartMic()
|
|
2960
|
+
return
|
|
2961
|
+
}
|
|
2628
2962
|
val currentEnabled = DeviceStore.get("glasses", "micEnabled") as? Boolean ?: false
|
|
2629
|
-
if (
|
|
2963
|
+
if (enabled && currentEnabled) {
|
|
2630
2964
|
restartMic()
|
|
2631
2965
|
return
|
|
2632
2966
|
}
|
|
@@ -2634,6 +2968,7 @@ class G2 : SGCManager() {
|
|
|
2634
2968
|
DeviceStore.apply("glasses", "micEnabled", enabled)
|
|
2635
2969
|
val msg = EvenHubProto.audioControlMessage(enabled)
|
|
2636
2970
|
sendEvenHubCommand(msg)
|
|
2971
|
+
evenHubMicActive = enabled
|
|
2637
2972
|
}
|
|
2638
2973
|
|
|
2639
2974
|
override fun sortMicRanking(list: MutableList<String>): MutableList<String> {
|
|
@@ -2658,9 +2993,9 @@ class G2 : SGCManager() {
|
|
|
2658
2993
|
}
|
|
2659
2994
|
|
|
2660
2995
|
override fun startVideoRecording(
|
|
2661
|
-
|
|
2662
|
-
|
|
2663
|
-
|
|
2996
|
+
requestId: String,
|
|
2997
|
+
save: Boolean,
|
|
2998
|
+
sound: Boolean
|
|
2664
2999
|
) {
|
|
2665
3000
|
Bridge.log("G2: startVideoRecording - not supported")
|
|
2666
3001
|
}
|
|
@@ -2719,6 +3054,7 @@ class G2 : SGCManager() {
|
|
|
2719
3054
|
override fun disconnect() {
|
|
2720
3055
|
Bridge.log("G2: disconnect()")
|
|
2721
3056
|
isDisconnecting = true
|
|
3057
|
+
clearDisplay()
|
|
2722
3058
|
cancelPairingTimeout()
|
|
2723
3059
|
stopScan()
|
|
2724
3060
|
stopHeartbeats()
|
|
@@ -2739,6 +3075,7 @@ class G2 : SGCManager() {
|
|
|
2739
3075
|
imageContainers.clear()
|
|
2740
3076
|
textContainers.clear()
|
|
2741
3077
|
dashboardShowing = 0
|
|
3078
|
+
dashboardOpening = false
|
|
2742
3079
|
heartbeatCounter = 0
|
|
2743
3080
|
currentBitmapBase64 = ""
|
|
2744
3081
|
menuAppIdToPackageName.clear()
|
|
@@ -2794,6 +3131,7 @@ class G2 : SGCManager() {
|
|
|
2794
3131
|
// } else {
|
|
2795
3132
|
// stopCompass()
|
|
2796
3133
|
// }
|
|
3134
|
+
displayScope.launch { runAuthSequence() }
|
|
2797
3135
|
}
|
|
2798
3136
|
|
|
2799
3137
|
/**
|
|
@@ -2836,23 +3174,20 @@ class G2 : SGCManager() {
|
|
|
2836
3174
|
suspend fun setImuEnabled(enabled: Boolean, reportFrq: Int) {
|
|
2837
3175
|
Bridge.log("G2: setImuEnabled($enabled, frq=$reportFrq)")
|
|
2838
3176
|
|
|
2839
|
-
|
|
2840
|
-
|
|
2841
|
-
|
|
2842
|
-
|
|
2843
|
-
|
|
2844
|
-
if (enabled && !pageCreated) {
|
|
2845
|
-
rebuildState()
|
|
2846
|
-
}
|
|
2847
|
-
|
|
2848
|
-
val msg =
|
|
2849
|
-
EvenHubProto.imuControlMessage(
|
|
2850
|
-
enable = enabled,
|
|
2851
|
-
reportFrq = reportFrq,
|
|
2852
|
-
magicRandom = sendManager.nextMagicRandom()
|
|
2853
|
-
)
|
|
2854
|
-
sendEvenHubCommand(msg)
|
|
3177
|
+
// IMU requires an active EvenHub page (same prerequisite as the mic). Await the rebuild so
|
|
3178
|
+
// the control packet is sent only after the page actually exists — page creation is async
|
|
3179
|
+
// with variable delays, so a fixed wait could send too early and reporting would never start.
|
|
3180
|
+
if (enabled && !pageCreated) {
|
|
3181
|
+
rebuildState()
|
|
2855
3182
|
}
|
|
3183
|
+
|
|
3184
|
+
val msg =
|
|
3185
|
+
EvenHubProto.imuControlMessage(
|
|
3186
|
+
enable = enabled,
|
|
3187
|
+
reportFrq = reportFrq,
|
|
3188
|
+
magicRandom = sendManager.nextMagicRandom()
|
|
3189
|
+
)
|
|
3190
|
+
sendEvenHubCommand(msg)
|
|
2856
3191
|
}
|
|
2857
3192
|
|
|
2858
3193
|
fun reconnectController() {
|
|
@@ -2941,9 +3276,6 @@ class G2 : SGCManager() {
|
|
|
2941
3276
|
}
|
|
2942
3277
|
|
|
2943
3278
|
override fun sendShutdown() {
|
|
2944
|
-
// Send the EvenHub shutdown synchronously before tearing down BLE. clearDisplay() is now
|
|
2945
|
-
// fire-and-forget (it serializes on displayScope), so calling it here would let disconnect()
|
|
2946
|
-
// close the GATT before the deferred shutdown packet was ever written.
|
|
2947
3279
|
val msg = EvenHubProto.shutdownMessage()
|
|
2948
3280
|
sendEvenHubCommand(msg)
|
|
2949
3281
|
pageCreated = false
|
|
@@ -2963,13 +3295,13 @@ class G2 : SGCManager() {
|
|
|
2963
3295
|
}
|
|
2964
3296
|
|
|
2965
3297
|
override fun sendRgbLedControl(
|
|
2966
|
-
|
|
2967
|
-
|
|
2968
|
-
|
|
2969
|
-
|
|
2970
|
-
|
|
2971
|
-
|
|
2972
|
-
|
|
3298
|
+
requestId: String,
|
|
3299
|
+
packageName: String?,
|
|
3300
|
+
action: String,
|
|
3301
|
+
color: String?,
|
|
3302
|
+
onDurationMs: Int,
|
|
3303
|
+
offDurationMs: Int,
|
|
3304
|
+
count: Int
|
|
2973
3305
|
) {
|
|
2974
3306
|
// G2 doesn't have RGB LEDs
|
|
2975
3307
|
Bridge.sendRgbLedControlResponse(requestId, false, "device_not_supported")
|
|
@@ -3010,11 +3342,11 @@ class G2 : SGCManager() {
|
|
|
3010
3342
|
stopScan()
|
|
3011
3343
|
|
|
3012
3344
|
val adapter =
|
|
3013
|
-
|
|
3014
|
-
|
|
3015
|
-
|
|
3016
|
-
|
|
3017
|
-
|
|
3345
|
+
bluetoothAdapter
|
|
3346
|
+
?: run {
|
|
3347
|
+
Bridge.log("G2: BluetoothAdapter not available")
|
|
3348
|
+
return false
|
|
3349
|
+
}
|
|
3018
3350
|
|
|
3019
3351
|
if (!adapter.isEnabled) {
|
|
3020
3352
|
Bridge.log("G2: Bluetooth not enabled")
|
|
@@ -3029,94 +3361,94 @@ class G2 : SGCManager() {
|
|
|
3029
3361
|
}
|
|
3030
3362
|
|
|
3031
3363
|
val scanner =
|
|
3032
|
-
|
|
3033
|
-
|
|
3034
|
-
|
|
3035
|
-
|
|
3036
|
-
|
|
3364
|
+
adapter.bluetoothLeScanner
|
|
3365
|
+
?: run {
|
|
3366
|
+
Bridge.log("G2: BluetoothLeScanner not available")
|
|
3367
|
+
return false
|
|
3368
|
+
}
|
|
3037
3369
|
|
|
3038
3370
|
val settings =
|
|
3039
|
-
|
|
3371
|
+
ScanSettings.Builder().setScanMode(ScanSettings.SCAN_MODE_LOW_LATENCY).build()
|
|
3040
3372
|
|
|
3041
3373
|
val callback =
|
|
3042
|
-
|
|
3043
|
-
|
|
3044
|
-
|
|
3045
|
-
|
|
3046
|
-
|
|
3047
|
-
|
|
3048
|
-
|
|
3049
|
-
|
|
3050
|
-
|
|
3051
|
-
|
|
3052
|
-
|
|
3053
|
-
|
|
3054
|
-
|
|
3055
|
-
|
|
3056
|
-
|
|
3374
|
+
object : ScanCallback() {
|
|
3375
|
+
override fun onScanResult(callbackType: Int, result: ScanResult?) {
|
|
3376
|
+
result ?: return
|
|
3377
|
+
val device = result.device ?: return
|
|
3378
|
+
val name = device.name ?: return
|
|
3379
|
+
|
|
3380
|
+
if (!name.contains("G2")) return
|
|
3381
|
+
|
|
3382
|
+
mainHandler.post {
|
|
3383
|
+
// Extract serial number from manufacturer data (like iOS)
|
|
3384
|
+
val serialNumber = extractSNFromScanRecord(result)
|
|
3385
|
+
if (serialNumber == null) {
|
|
3386
|
+
Bridge.log("G2: Discovered: $name but no SN in mfg data")
|
|
3387
|
+
return@post
|
|
3388
|
+
}
|
|
3057
3389
|
|
|
3058
|
-
|
|
3059
|
-
|
|
3060
|
-
|
|
3061
|
-
|
|
3062
|
-
|
|
3063
|
-
|
|
3064
|
-
|
|
3065
|
-
|
|
3066
|
-
|
|
3067
|
-
|
|
3068
|
-
|
|
3069
|
-
|
|
3070
|
-
|
|
3071
|
-
|
|
3072
|
-
|
|
3073
|
-
|
|
3074
|
-
|
|
3075
|
-
}
|
|
3076
|
-
}
|
|
3077
|
-
// Stop scanning once we have both
|
|
3078
|
-
if (leftGatt != null && rightGatt != null) {
|
|
3079
|
-
stopScan()
|
|
3080
|
-
Bridge.log("G2: Stopped scan after discovering both devices")
|
|
3081
|
-
return@post
|
|
3390
|
+
val mfgFirst = result.scanRecord?.manufacturerSpecificData?.valueAt(0)
|
|
3391
|
+
val mfgHex =
|
|
3392
|
+
mfgFirst?.joinToString(" ") { String.format("%02X", it) }
|
|
3393
|
+
?: "none"
|
|
3394
|
+
Bridge.log(
|
|
3395
|
+
"G2: Discovered: $name (SN: $serialNumber) mfgData[${mfgFirst?.size ?: 0}]: $mfgHex"
|
|
3396
|
+
)
|
|
3397
|
+
deviceNameToSerialNumber[name] = serialNumber
|
|
3398
|
+
|
|
3399
|
+
// Save MAC per side; ring's advStart needs the left lens MAC.
|
|
3400
|
+
val mac = extractMacFromScanRecord(result)
|
|
3401
|
+
if (mac != null) {
|
|
3402
|
+
if (name.contains("_L_")) {
|
|
3403
|
+
DeviceStore.apply("glasses", "leftMacAddress", mac)
|
|
3404
|
+
DeviceStore.apply("glasses", "bluetoothMacAddress", mac)
|
|
3405
|
+
} else if (name.contains("_R_")) {
|
|
3406
|
+
DeviceStore.apply("glasses", "rightMacAddress", mac)
|
|
3082
3407
|
}
|
|
3408
|
+
}
|
|
3409
|
+
// Stop scanning once we have both
|
|
3410
|
+
if (leftGatt != null && rightGatt != null) {
|
|
3411
|
+
stopScan()
|
|
3412
|
+
Bridge.log("G2: Stopped scan after discovering both devices")
|
|
3413
|
+
return@post
|
|
3414
|
+
}
|
|
3083
3415
|
|
|
3084
|
-
|
|
3085
|
-
|
|
3416
|
+
// Always emit discovered device to frontend
|
|
3417
|
+
emitDiscoveredDevice(serialNumber)
|
|
3086
3418
|
|
|
3087
|
-
|
|
3088
|
-
|
|
3419
|
+
// If scan-only mode, don't auto-connect
|
|
3420
|
+
if (DEVICE_SEARCH_ID == "NOT_SET") return@post
|
|
3089
3421
|
|
|
3090
|
-
|
|
3091
|
-
|
|
3422
|
+
// Only connect to devices matching our search ID
|
|
3423
|
+
if (!serialNumber.contains(DEVICE_SEARCH_ID)) return@post
|
|
3092
3424
|
|
|
3093
|
-
|
|
3094
|
-
|
|
3095
|
-
|
|
3096
|
-
|
|
3097
|
-
}
|
|
3098
|
-
} else if (name.contains("_R_")) {
|
|
3099
|
-
if (rightGatt == null) {
|
|
3100
|
-
Bridge.log("G2: Connecting to RIGHT: $name")
|
|
3101
|
-
rightGatt =
|
|
3102
|
-
device.connectGatt(context, false, rightGattCallback)
|
|
3103
|
-
}
|
|
3425
|
+
if (name.contains("_L_")) {
|
|
3426
|
+
if (leftGatt == null) {
|
|
3427
|
+
Bridge.log("G2: Connecting to LEFT: $name")
|
|
3428
|
+
leftGatt = device.connectGatt(context, false, leftGattCallback)
|
|
3104
3429
|
}
|
|
3105
|
-
|
|
3106
|
-
|
|
3107
|
-
|
|
3108
|
-
|
|
3109
|
-
|
|
3110
|
-
Bridge.log("G2: Stopped scan after discovering both devices2")
|
|
3430
|
+
} else if (name.contains("_R_")) {
|
|
3431
|
+
if (rightGatt == null) {
|
|
3432
|
+
Bridge.log("G2: Connecting to RIGHT: $name")
|
|
3433
|
+
rightGatt =
|
|
3434
|
+
device.connectGatt(context, false, rightGattCallback)
|
|
3111
3435
|
}
|
|
3112
3436
|
}
|
|
3113
|
-
}
|
|
3114
3437
|
|
|
3115
|
-
|
|
3116
|
-
|
|
3438
|
+
// Stop scanning once we have both
|
|
3439
|
+
if (leftGatt != null && rightGatt != null) {
|
|
3440
|
+
stopScan()
|
|
3441
|
+
cancelPairingTimeout()
|
|
3442
|
+
Bridge.log("G2: Stopped scan after discovering both devices2")
|
|
3443
|
+
}
|
|
3117
3444
|
}
|
|
3118
3445
|
}
|
|
3119
3446
|
|
|
3447
|
+
override fun onScanFailed(errorCode: Int) {
|
|
3448
|
+
Bridge.log("G2: Scan failed with error code: $errorCode")
|
|
3449
|
+
}
|
|
3450
|
+
}
|
|
3451
|
+
|
|
3120
3452
|
scanCallback = callback
|
|
3121
3453
|
try {
|
|
3122
3454
|
scanner.startScan(null, settings, callback)
|
|
@@ -3154,7 +3486,7 @@ class G2 : SGCManager() {
|
|
|
3154
3486
|
val rightDevice = adapter.getRemoteDevice(rightAddr)
|
|
3155
3487
|
|
|
3156
3488
|
Bridge.log(
|
|
3157
|
-
|
|
3489
|
+
"G2: connectByAddress - left: ${leftDevice.name ?: leftAddr}, right: ${rightDevice.name ?: rightAddr}"
|
|
3158
3490
|
)
|
|
3159
3491
|
|
|
3160
3492
|
leftGatt = leftDevice.connectGatt(context, false, leftGattCallback)
|
|
@@ -3186,8 +3518,8 @@ class G2 : SGCManager() {
|
|
|
3186
3518
|
// Read 14 bytes of ASCII SN starting at offset 0
|
|
3187
3519
|
val snBytes = data.copyOfRange(0, minOf(14, data.size))
|
|
3188
3520
|
val sn =
|
|
3189
|
-
|
|
3190
|
-
|
|
3521
|
+
String(snBytes, Charsets.US_ASCII)
|
|
3522
|
+
.replace(Regex("[\\x00-\\x1F\\x7F]"), "") // Strip control chars
|
|
3191
3523
|
return if (sn.isNotEmpty()) sn else null
|
|
3192
3524
|
}
|
|
3193
3525
|
|
|
@@ -3246,17 +3578,17 @@ class G2 : SGCManager() {
|
|
|
3246
3578
|
// discoverServices is deferred to onMtuChanged so the larger MTU is in
|
|
3247
3579
|
// effect for the rest of the setup.
|
|
3248
3580
|
val mtuRequested =
|
|
3249
|
-
|
|
3250
|
-
|
|
3251
|
-
|
|
3252
|
-
|
|
3253
|
-
|
|
3254
|
-
|
|
3255
|
-
|
|
3256
|
-
|
|
3581
|
+
try {
|
|
3582
|
+
gatt.requestMtu(247)
|
|
3583
|
+
} catch (e: SecurityException) {
|
|
3584
|
+
Bridge.log(
|
|
3585
|
+
"G2: requestMtu SecurityException on $side: ${e.message}"
|
|
3586
|
+
)
|
|
3587
|
+
false
|
|
3588
|
+
}
|
|
3257
3589
|
if (!mtuRequested) {
|
|
3258
3590
|
Bridge.log(
|
|
3259
|
-
|
|
3591
|
+
"G2: requestMtu returned false on $side, proceeding without MTU bump"
|
|
3260
3592
|
)
|
|
3261
3593
|
gatt.discoverServices()
|
|
3262
3594
|
}
|
|
@@ -3268,7 +3600,7 @@ class G2 : SGCManager() {
|
|
|
3268
3600
|
gatt.requestConnectionPriority(BluetoothGatt.CONNECTION_PRIORITY_HIGH)
|
|
3269
3601
|
} catch (e: SecurityException) {
|
|
3270
3602
|
Bridge.log(
|
|
3271
|
-
|
|
3603
|
+
"G2: requestConnectionPriority SecurityException on $side: ${e.message}"
|
|
3272
3604
|
)
|
|
3273
3605
|
}
|
|
3274
3606
|
} else if (newState == BluetoothProfile.STATE_DISCONNECTED) {
|
|
@@ -3296,6 +3628,7 @@ class G2 : SGCManager() {
|
|
|
3296
3628
|
startupPageCreated = false
|
|
3297
3629
|
pageCreated = false
|
|
3298
3630
|
dashboardShowing = 0
|
|
3631
|
+
dashboardOpening = false
|
|
3299
3632
|
DeviceStore.apply("glasses", "connected", false)
|
|
3300
3633
|
DeviceStore.apply("glasses", "fullyBooted", false)
|
|
3301
3634
|
|
|
@@ -3330,17 +3663,17 @@ class G2 : SGCManager() {
|
|
|
3330
3663
|
|
|
3331
3664
|
var propStr = mutableListOf<String>()
|
|
3332
3665
|
if (props and BluetoothGattCharacteristic.PROPERTY_READ != 0)
|
|
3333
|
-
|
|
3666
|
+
propStr.add("read")
|
|
3334
3667
|
if (props and BluetoothGattCharacteristic.PROPERTY_WRITE != 0)
|
|
3335
|
-
|
|
3668
|
+
propStr.add("write")
|
|
3336
3669
|
if (props and BluetoothGattCharacteristic.PROPERTY_WRITE_NO_RESPONSE !=
|
|
3337
|
-
|
|
3670
|
+
0
|
|
3338
3671
|
)
|
|
3339
|
-
|
|
3672
|
+
propStr.add("writeNoResp")
|
|
3340
3673
|
if (props and BluetoothGattCharacteristic.PROPERTY_NOTIFY != 0)
|
|
3341
|
-
|
|
3674
|
+
propStr.add("notify")
|
|
3342
3675
|
if (props and BluetoothGattCharacteristic.PROPERTY_INDICATE != 0)
|
|
3343
|
-
|
|
3676
|
+
propStr.add("indicate")
|
|
3344
3677
|
Bridge.log("G2: $side char $uuid props=[${propStr.joinToString(",")}]")
|
|
3345
3678
|
|
|
3346
3679
|
when (uuid) {
|
|
@@ -3349,12 +3682,14 @@ class G2 : SGCManager() {
|
|
|
3349
3682
|
if (side == "LEFT") leftWriteChar = char
|
|
3350
3683
|
else rightWriteChar = char
|
|
3351
3684
|
}
|
|
3685
|
+
|
|
3352
3686
|
G2BLE.CHAR_NOTIFY -> {
|
|
3353
3687
|
Bridge.log("G2: Found NOTIFY char on $side")
|
|
3354
3688
|
if (side == "LEFT") leftNotifyChar = char
|
|
3355
3689
|
else rightNotifyChar = char
|
|
3356
3690
|
enqueueGattOp { enableNotifications(gatt, char) }
|
|
3357
3691
|
}
|
|
3692
|
+
|
|
3358
3693
|
G2BLE.AUDIO_NOTIFY -> {
|
|
3359
3694
|
Bridge.log("G2: Found AUDIO char on $side")
|
|
3360
3695
|
if (side == "LEFT") leftAudioChar = char
|
|
@@ -3388,22 +3723,30 @@ class G2 : SGCManager() {
|
|
|
3388
3723
|
|
|
3389
3724
|
@Deprecated("Deprecated in API level 33")
|
|
3390
3725
|
override fun onCharacteristicChanged(
|
|
3391
|
-
|
|
3392
|
-
|
|
3726
|
+
gatt: BluetoothGatt,
|
|
3727
|
+
characteristic: BluetoothGattCharacteristic
|
|
3393
3728
|
) {
|
|
3394
3729
|
val data = characteristic.value ?: return
|
|
3395
3730
|
|
|
3396
3731
|
val sourceKey = if (side == "LEFT") "L" else "R"
|
|
3397
3732
|
when (characteristic.uuid) {
|
|
3398
3733
|
G2BLE.AUDIO_NOTIFY -> handleAudioData(data, sourceKey)
|
|
3399
|
-
G2BLE.CHAR_NOTIFY ->
|
|
3734
|
+
G2BLE.CHAR_NOTIFY -> {
|
|
3735
|
+
// Correlate an in-flight image ACK INLINE on the BLE callback thread, before
|
|
3736
|
+
// posting the rest of the handling to the (potentially backed-up) main
|
|
3737
|
+
// queue. This guarantees the ACK that sendImageData() is awaiting resolves
|
|
3738
|
+
// promptly even while the main looper is busy draining a packet burst /
|
|
3739
|
+
// heartbeats — the cause of the intermittent "glasses stopped responding".
|
|
3740
|
+
correlateImageAck(data, sourceKey)
|
|
3741
|
+
mainHandler.post { handleNotifyData(data, sourceKey) }
|
|
3742
|
+
}
|
|
3400
3743
|
}
|
|
3401
3744
|
}
|
|
3402
3745
|
|
|
3403
3746
|
override fun onDescriptorWrite(
|
|
3404
|
-
|
|
3405
|
-
|
|
3406
|
-
|
|
3747
|
+
gatt: BluetoothGatt,
|
|
3748
|
+
descriptor: BluetoothGattDescriptor,
|
|
3749
|
+
status: Int
|
|
3407
3750
|
) {
|
|
3408
3751
|
mainHandler.post {
|
|
3409
3752
|
// Process next queued GATT operation
|
|
@@ -3431,8 +3774,8 @@ class G2 : SGCManager() {
|
|
|
3431
3774
|
|
|
3432
3775
|
@Suppress("deprecation")
|
|
3433
3776
|
private fun enableNotifications(
|
|
3434
|
-
|
|
3435
|
-
|
|
3777
|
+
gatt: BluetoothGatt,
|
|
3778
|
+
characteristic: BluetoothGattCharacteristic
|
|
3436
3779
|
) {
|
|
3437
3780
|
gatt.setCharacteristicNotification(characteristic, true)
|
|
3438
3781
|
val descriptor = characteristic.getDescriptor(G2BLE.CLIENT_CHARACTERISTIC_CONFIG)
|
|
@@ -3448,12 +3791,66 @@ class G2 : SGCManager() {
|
|
|
3448
3791
|
|
|
3449
3792
|
// ---------- Incoming Data Handling ----------
|
|
3450
3793
|
|
|
3794
|
+
/**
|
|
3795
|
+
* Resolve an in-flight image-fragment ACK directly from a raw notify packet, on the BLE
|
|
3796
|
+
* callback thread. This runs BEFORE [handleNotifyData] is posted to [mainHandler] so the ACK
|
|
3797
|
+
* that [sendImageData] is awaiting completes promptly even when the main looper is saturated by
|
|
3798
|
+
* a packet burst, heartbeats, or the text-queue tick — the root cause of the intermittent
|
|
3799
|
+
* "glasses stop responding to image sends".
|
|
3800
|
+
*
|
|
3801
|
+
* Deliberately a read-only, SINGLE-PACKET parse: it does NOT touch the stateful [receiveManager]
|
|
3802
|
+
* (that stays exclusively on the main thread). An ImgResCmd always fits in one BLE packet, so a
|
|
3803
|
+
* multi-packet frame (totalPackets > 1) is not an image ACK and is left entirely to the
|
|
3804
|
+
* main-thread path. Completing an already-completed CompletableDeferred is a no-op, so the
|
|
3805
|
+
* duplicate L/R ACK and the residual main-thread correlation are both harmless.
|
|
3806
|
+
*/
|
|
3807
|
+
private fun correlateImageAck(rawData: ByteArray, @Suppress("UNUSED_PARAMETER") sourceKey: String) {
|
|
3808
|
+
// Fast path: only proceed if a transfer is actually outstanding.
|
|
3809
|
+
val ack = pendingImgAck ?: return
|
|
3810
|
+
if (rawData.size < 8) return
|
|
3811
|
+
if (rawData[0] != G2BLE.HEADER_BYTE) return
|
|
3812
|
+
|
|
3813
|
+
val payloadLen = rawData[3].toInt() and 0xFF
|
|
3814
|
+
val expectedLen = payloadLen + 8
|
|
3815
|
+
if (rawData.size < expectedLen) return
|
|
3816
|
+
|
|
3817
|
+
val totalPackets = rawData[4].toInt() and 0xFF
|
|
3818
|
+
val serialNum = rawData[5].toInt() and 0xFF
|
|
3819
|
+
val serviceId = rawData[6]
|
|
3820
|
+
val status = rawData[7].toInt() and 0xFF
|
|
3821
|
+
val resultCode = (status shr 1) and 0x0F
|
|
3822
|
+
if (resultCode != 0) return
|
|
3823
|
+
if (serviceId != ServiceID.EVEN_HUB.value) return
|
|
3824
|
+
// Single complete packet only (ImgResCmd never spans packets); else defer to main thread.
|
|
3825
|
+
if (totalPackets != 1 || serialNum != 1) return
|
|
3826
|
+
|
|
3827
|
+
// Last packet carries a 2-byte CRC trailer; strip it from the payload.
|
|
3828
|
+
val payloadEnd = 8 + payloadLen - 2
|
|
3829
|
+
if (payloadEnd < 8 || payloadEnd > rawData.size) return
|
|
3830
|
+
val payload = rawData.copyOfRange(8, payloadEnd)
|
|
3831
|
+
|
|
3832
|
+
val fields = ProtobufReader(payload).parseFields()
|
|
3833
|
+
val resData = fields[6] as? ByteArray ?: return // field 6 = ImgResCmd
|
|
3834
|
+
val resFields = ProtobufReader(resData).parseFields()
|
|
3835
|
+
val errorCode = resFields[8] as? Int ?: return
|
|
3836
|
+
val ackSession = resFields[3] as? Int ?: return
|
|
3837
|
+
val ackFragment = (resFields[6] as? Int) ?: 0
|
|
3838
|
+
Bridge.log("G2: img_res: session=$ackSession fragment=$ackFragment errorCode=$errorCode success=${errorCode == 4}")
|
|
3839
|
+
if (ackSession == pendingImgAckSession && ackFragment == pendingImgAckFragment) {
|
|
3840
|
+
ack.complete(errorCode == 4)
|
|
3841
|
+
}
|
|
3842
|
+
}
|
|
3843
|
+
|
|
3451
3844
|
private fun handleNotifyData(data: ByteArray, sourceKey: String) {
|
|
3452
3845
|
val result = receiveManager.handlePacket(data, sourceKey) ?: return
|
|
3453
3846
|
|
|
3454
3847
|
val serviceId = result.first
|
|
3455
3848
|
val payload = result.second
|
|
3456
3849
|
|
|
3850
|
+
|
|
3851
|
+
// print raw log, first 32 bytes:
|
|
3852
|
+
// Bridge.log("G2: handleNotifyData() - serviceId=$serviceId, payload=${payload.take(32).joinToString("") { String.format("%02X", it) }}")
|
|
3853
|
+
|
|
3457
3854
|
when (serviceId) {
|
|
3458
3855
|
ServiceID.EVEN_HUB.value -> handleEvenHubResponse(payload)
|
|
3459
3856
|
ServiceID.DEVICE_SETTINGS.value -> handleDevSettingsResponse(payload, sourceKey)
|
|
@@ -3466,7 +3863,7 @@ class G2 : SGCManager() {
|
|
|
3466
3863
|
ServiceID.EVEN_HUB_CTRL.value -> handleEvenHubCtrlResponse(payload)
|
|
3467
3864
|
else -> {
|
|
3468
3865
|
Bridge.log(
|
|
3469
|
-
|
|
3866
|
+
"G2: Unhandled service ${serviceId.toInt() and 0xFF} (${payload.size} bytes): ${
|
|
3470
3867
|
payload.take(32).joinToString("") { String.format("%02X", it) }
|
|
3471
3868
|
}"
|
|
3472
3869
|
)
|
|
@@ -3488,11 +3885,18 @@ class G2 : SGCManager() {
|
|
|
3488
3885
|
val cFields = cReader.parseFields()
|
|
3489
3886
|
val voiceSwitch = cFields[1] as? Int ?: 0 // omitted = 0 = OFF
|
|
3490
3887
|
Bridge.log(
|
|
3491
|
-
|
|
3888
|
+
"G2: EvenAI CONFIG echo — voiceSwitch=$voiceSwitch (${if (voiceSwitch == 1) "ON" else "OFF"}) config=$cFields"
|
|
3492
3889
|
)
|
|
3493
3890
|
} else {
|
|
3494
3891
|
Bridge.log(
|
|
3495
|
-
|
|
3892
|
+
"G2: EvenAI cmd=$cmd fields=${fields.keys.sorted()} raw=${
|
|
3893
|
+
payload.joinToString("") {
|
|
3894
|
+
String.format(
|
|
3895
|
+
"%02X",
|
|
3896
|
+
it
|
|
3897
|
+
)
|
|
3898
|
+
}
|
|
3899
|
+
}"
|
|
3496
3900
|
)
|
|
3497
3901
|
}
|
|
3498
3902
|
}
|
|
@@ -3519,17 +3923,19 @@ class G2 : SGCManager() {
|
|
|
3519
3923
|
// Heading in degrees, 0…359.
|
|
3520
3924
|
Bridge.log("G2: compass heading=$heading°")
|
|
3521
3925
|
Bridge.sendTypedMessage(
|
|
3522
|
-
|
|
3523
|
-
|
|
3524
|
-
|
|
3525
|
-
|
|
3526
|
-
|
|
3926
|
+
"CompassHeadingEvent",
|
|
3927
|
+
mapOf(
|
|
3928
|
+
"heading" to heading,
|
|
3929
|
+
"timestamp" to System.currentTimeMillis()
|
|
3930
|
+
)
|
|
3527
3931
|
)
|
|
3528
3932
|
}
|
|
3933
|
+
|
|
3529
3934
|
NavigationCmd.OS_NOTIFY_COMPASS_CALIBRATE_START.value -> {
|
|
3530
3935
|
Bridge.log("G2: compass calibration started — wearer should look around")
|
|
3531
3936
|
Bridge.sendTypedMessage("CompassCalibrationEvent", mapOf("status" to "start"))
|
|
3532
3937
|
}
|
|
3938
|
+
|
|
3533
3939
|
NavigationCmd.OS_NOTIFY_COMPASS_CALIBRATE_COMPLETE.value -> {
|
|
3534
3940
|
Bridge.log("G2: compass calibration complete")
|
|
3535
3941
|
Bridge.sendTypedMessage("CompassCalibrationEvent", mapOf("status" to "complete"))
|
|
@@ -3542,18 +3948,23 @@ class G2 : SGCManager() {
|
|
|
3542
3948
|
val fields = reader.parseFields()
|
|
3543
3949
|
|
|
3544
3950
|
// print raw payload:
|
|
3545
|
-
|
|
3951
|
+
val payloadStr = payload.joinToString("") { String.format("%02X", it) }
|
|
3952
|
+
if (payloadStr.contains("080C7A02100C") || payloadStr.contains("080652020808")) {
|
|
3953
|
+
// heartbeat response
|
|
3954
|
+
return
|
|
3955
|
+
}
|
|
3956
|
+
Bridge.log("G2: res: ${payload.joinToString("") { String.format("%02X", it) }}")
|
|
3546
3957
|
|
|
3547
3958
|
val cmdValue =
|
|
3548
|
-
|
|
3549
|
-
|
|
3550
|
-
|
|
3551
|
-
|
|
3552
|
-
|
|
3553
|
-
|
|
3554
|
-
|
|
3555
|
-
|
|
3556
|
-
|
|
3959
|
+
fields[1] as? Int
|
|
3960
|
+
?: run {
|
|
3961
|
+
Bridge.log(
|
|
3962
|
+
"G2: EvenHub response - no cmd field, ${payload.size} bytes: ${
|
|
3963
|
+
payload.joinToString("") { String.format("%02X", it) }
|
|
3964
|
+
}"
|
|
3965
|
+
)
|
|
3966
|
+
return
|
|
3967
|
+
}
|
|
3557
3968
|
|
|
3558
3969
|
if (cmdValue == EvenHubResponseCmd.OS_NOTIFY_EVENT_TO_APP.value) {
|
|
3559
3970
|
// Touch/gesture event from glasses
|
|
@@ -3590,8 +4001,10 @@ class G2 : SGCManager() {
|
|
|
3590
4001
|
Bridge.log("G2: Menu selection ignored — placeholder or unknown appId=$appId")
|
|
3591
4002
|
}
|
|
3592
4003
|
} else {
|
|
3593
|
-
//
|
|
3594
|
-
//
|
|
4004
|
+
// NOTE: the per-fragment image ACK is correlated inline on the BLE callback thread in
|
|
4005
|
+
// correlateImageAck() (called before this runs is posted to the main looper) so it is
|
|
4006
|
+
// never delayed behind a saturated main queue. Nothing to do here for ImgResCmd.
|
|
4007
|
+
|
|
3595
4008
|
val timestamp = System.currentTimeMillis()
|
|
3596
4009
|
val lastResponse = lastEvenHubResponseTimestamp
|
|
3597
4010
|
if (lastResponse != null && timestamp - lastResponse < 100) {
|
|
@@ -3603,6 +4016,7 @@ class G2 : SGCManager() {
|
|
|
3603
4016
|
if (cmdValue == 9 || cmdValue == 10) {
|
|
3604
4017
|
Bridge.log("G2: ERROR: Glasses shutdown our EvenHub page — resetting page state")
|
|
3605
4018
|
pageCreated = false
|
|
4019
|
+
evenHubMicActive = false // mic dies with the page
|
|
3606
4020
|
}
|
|
3607
4021
|
|
|
3608
4022
|
// Scan response fields for a shutdown/error code regardless of the debounce window.
|
|
@@ -3617,26 +4031,28 @@ class G2 : SGCManager() {
|
|
|
3617
4031
|
// 7=rebuild_failed, 8=text_success, 9=text_failed
|
|
3618
4032
|
if (errorCode == 9) {
|
|
3619
4033
|
Bridge.log(
|
|
3620
|
-
|
|
4034
|
+
"G2: WARN: Glasses shutdown our EvenHub page — resetting page state"
|
|
3621
4035
|
)
|
|
3622
4036
|
pageCreated = false
|
|
4037
|
+
evenHubMicActive = false // mic dies with the page
|
|
3623
4038
|
}
|
|
3624
4039
|
}
|
|
3625
4040
|
}
|
|
3626
4041
|
|
|
3627
|
-
for (resField in listOf(4, 6, 8, 10)) {
|
|
3628
|
-
|
|
3629
|
-
|
|
3630
|
-
|
|
3631
|
-
|
|
3632
|
-
|
|
3633
|
-
|
|
3634
|
-
|
|
3635
|
-
|
|
3636
|
-
|
|
3637
|
-
|
|
3638
|
-
|
|
3639
|
-
}
|
|
4042
|
+
// for (resField in listOf(4, 6, 8, 10)) {
|
|
4043
|
+
// val resData = fields[resField] as? ByteArray ?: continue
|
|
4044
|
+
// val resReader = ProtobufReader(resData)
|
|
4045
|
+
// val resFields = resReader.parseFields()
|
|
4046
|
+
// (resFields[8] as? Int)?.let { errorCode ->
|
|
4047
|
+
// // ImgResCmd ErrorCode in field 8 (the sendImageData ACK is completed above,
|
|
4048
|
+
// // before the dedup window — this is just the deduped logging path).
|
|
4049
|
+
// if (errorCode == 4) {
|
|
4050
|
+
// Bridge.log("G2: img_success")
|
|
4051
|
+
// } else {
|
|
4052
|
+
// Bridge.log("G2: EvenHub ImgRes errorCode=$errorCode")
|
|
4053
|
+
// }
|
|
4054
|
+
// }
|
|
4055
|
+
// }
|
|
3640
4056
|
}
|
|
3641
4057
|
}
|
|
3642
4058
|
|
|
@@ -3653,9 +4069,9 @@ class G2 : SGCManager() {
|
|
|
3653
4069
|
|
|
3654
4070
|
private fun setControllerFullyConnected() {
|
|
3655
4071
|
val isControllerConnected =
|
|
3656
|
-
|
|
4072
|
+
DeviceStore.get("glasses", "controllerConnected") as? Boolean ?: false
|
|
3657
4073
|
val isControllerFullyBooted =
|
|
3658
|
-
|
|
4074
|
+
DeviceStore.get("glasses", "controllerFullyBooted") as? Boolean ?: false
|
|
3659
4075
|
if (!isControllerConnected) {
|
|
3660
4076
|
DeviceStore.apply("glasses", "controllerConnected", true)
|
|
3661
4077
|
}
|
|
@@ -3727,7 +4143,7 @@ class G2 : SGCManager() {
|
|
|
3727
4143
|
|
|
3728
4144
|
val normalType = sysFields[1] as? Int
|
|
3729
4145
|
val eventType: OsEventType? =
|
|
3730
|
-
|
|
4146
|
+
if (normalType != null) OsEventType.fromInt(normalType) else OsEventType.CLICK
|
|
3731
4147
|
val eventSource: Int? = sysFields[2] as? Int
|
|
3732
4148
|
|
|
3733
4149
|
if (eventType == null) {
|
|
@@ -3762,13 +4178,15 @@ class G2 : SGCManager() {
|
|
|
3762
4178
|
}
|
|
3763
4179
|
}
|
|
3764
4180
|
|
|
3765
|
-
// System exit:
|
|
3766
|
-
//
|
|
4181
|
+
// System exit: the firmware killed our page (and the mic). ONLY mark state dead; do
|
|
4182
|
+
// NOT rebuild here. systemExit is fired alongside the dashboard-close (08011A00) event
|
|
4183
|
+
// for the same transition — if both rebuilt, the fresh page gets torn down again →
|
|
4184
|
+
// rebuild→exit→rebuild loop. Recovery is owned by one place: the dashboard-close
|
|
4185
|
+
// handler (and the reconcile page-down path). Don't touch micEnabled (user intent) —
|
|
4186
|
+
// recovery reads it to re-arm; clobbering it strands the mic.
|
|
3767
4187
|
if (eventType == OsEventType.SYSTEM_EXIT || eventType == OsEventType.ABNORMAL_EXIT) {
|
|
3768
4188
|
pageCreated = false
|
|
3769
|
-
//
|
|
3770
|
-
DeviceStore.apply("glasses", "micEnabled", false)
|
|
3771
|
-
DeviceManager.getInstance().updateMicState()
|
|
4189
|
+
evenHubMicActive = false // firmware killed the mic with the page
|
|
3772
4190
|
}
|
|
3773
4191
|
return
|
|
3774
4192
|
}
|
|
@@ -3884,13 +4302,13 @@ class G2 : SGCManager() {
|
|
|
3884
4302
|
|
|
3885
4303
|
private fun handleMenuResponse(payload: ByteArray) {
|
|
3886
4304
|
Bridge.log(
|
|
3887
|
-
|
|
4305
|
+
"G2: menu response: ${payload.take(32).joinToString("") { String.format("%02X", it) }}"
|
|
3888
4306
|
)
|
|
3889
4307
|
}
|
|
3890
4308
|
|
|
3891
4309
|
private fun handleDashboardResponse(payload: ByteArray) {
|
|
3892
4310
|
Bridge.log(
|
|
3893
|
-
|
|
4311
|
+
"G2: dashboard response: ${payload.take(32).joinToString("") { String.format("%02X", it) }}"
|
|
3894
4312
|
)
|
|
3895
4313
|
val reader = ProtobufReader(payload)
|
|
3896
4314
|
val fields = reader.parseFields()
|
|
@@ -3922,7 +4340,7 @@ class G2 : SGCManager() {
|
|
|
3922
4340
|
|
|
3923
4341
|
private fun handleEvenHubCtrlResponse(payload: ByteArray) {
|
|
3924
4342
|
Bridge.log(
|
|
3925
|
-
|
|
4343
|
+
"G2: evenHubCtrl response: ${payload.take(8).joinToString("") { String.format("%02X", it) }}"
|
|
3926
4344
|
)
|
|
3927
4345
|
}
|
|
3928
4346
|
|
|
@@ -3936,50 +4354,21 @@ class G2 : SGCManager() {
|
|
|
3936
4354
|
}
|
|
3937
4355
|
lastGestureCtrlTimestamp = timestamp
|
|
3938
4356
|
|
|
3939
|
-
//
|
|
4357
|
+
// 08011A00 is the dashboard open/close toggle — it fires on BOTH transitions. Use the
|
|
4358
|
+
// dashboardOpening latch (set by showDashboard) to tell them apart:
|
|
4359
|
+
// • First event after showDashboard → OPEN confirm. Consume it, keep the dashboard up,
|
|
4360
|
+
// do NOT recover (recovering rebuilds our page and snatches the screen back — the
|
|
4361
|
+
// "double-tap flickers captions but never opens the dashboard" bug).
|
|
4362
|
+
// • Next event → real CLOSE. Reset state and recover our page + mic.
|
|
3940
4363
|
if (payload.contentEquals(byteArrayOf(0x08, 0x01, 0x1A, 0x00))) {
|
|
3941
|
-
Bridge.log("G2: dashboard
|
|
3942
|
-
|
|
3943
|
-
|
|
3944
|
-
dashboardShowing = 0
|
|
3945
|
-
// Rebuild the page from cached containers, then reconcile against
|
|
3946
|
-
// DeviceManager's authoritative current view so the glasses match the phone
|
|
3947
|
-
// (not just the last-cached G2 containers) after returning from the dashboard.
|
|
3948
|
-
displayScope.launch {
|
|
3949
|
-
displayMutex.withLock { rebuildState() }
|
|
3950
|
-
DeviceManager.getInstance().sendCurrentState()
|
|
3951
|
-
// set the mic back on if it should be on — only after the rebuild
|
|
3952
|
-
// completes, matching iOS (which awaits rebuildState before restartMic).
|
|
3953
|
-
val micEnabled = DeviceStore.get("glasses", "micEnabled") as? Boolean ?: false
|
|
3954
|
-
if (micEnabled) {
|
|
3955
|
-
restartMic()
|
|
3956
|
-
}
|
|
3957
|
-
}
|
|
4364
|
+
Bridge.log("G2: dashboard toggle - dashboardShowing=$dashboardShowing opening=$dashboardOpening")
|
|
4365
|
+
if (dashboardOpening) {
|
|
4366
|
+
dashboardOpening = false // open confirmed; dashboard now owns the screen
|
|
3958
4367
|
return
|
|
3959
|
-
} else {
|
|
3960
|
-
// if we aren't trying to show the dashboard
|
|
3961
|
-
// then we need to turn the mic back on and display the mentra main page:
|
|
3962
|
-
if (dashboardShowing <= 1) {
|
|
3963
|
-
dashboardShowing = 0
|
|
3964
|
-
// Rebuild the page from cached containers, then reconcile against
|
|
3965
|
-
// DeviceManager's authoritative current view so the glasses match the phone
|
|
3966
|
-
// (not just the last-cached G2 containers) after returning from the dashboard.
|
|
3967
|
-
displayScope.launch {
|
|
3968
|
-
displayMutex.withLock { rebuildState() }
|
|
3969
|
-
// set the mic back on if it should be on
|
|
3970
|
-
val micEnabled = DeviceStore.get("glasses", "micEnabled") as? Boolean ?: false
|
|
3971
|
-
if (micEnabled) {
|
|
3972
|
-
restartMic()
|
|
3973
|
-
}
|
|
3974
|
-
}
|
|
3975
|
-
return
|
|
3976
|
-
}
|
|
3977
|
-
// do nothing this time since we just closed the dashboard
|
|
3978
|
-
dashboardShowing -= 1
|
|
3979
|
-
if (dashboardShowing < 0) {
|
|
3980
|
-
dashboardShowing = 0
|
|
3981
|
-
}
|
|
3982
4368
|
}
|
|
4369
|
+
dashboardShowing = 0
|
|
4370
|
+
recoverPageAndMic("dashboard-close")
|
|
4371
|
+
return
|
|
3983
4372
|
}
|
|
3984
4373
|
}
|
|
3985
4374
|
|
|
@@ -3990,7 +4379,7 @@ class G2 : SGCManager() {
|
|
|
3990
4379
|
val cmdValue = fields[1] as? Int ?: return
|
|
3991
4380
|
|
|
3992
4381
|
if (cmdValue == G2SettingCommandId.DEVICE_RECEIVE_REQUEST.value ||
|
|
3993
|
-
|
|
4382
|
+
cmdValue == G2SettingCommandId.DEVICE_SEND_TO_APP.value
|
|
3994
4383
|
) {
|
|
3995
4384
|
(fields[4] as? ByteArray)?.let { parseDeviceRequestResponse(it) }
|
|
3996
4385
|
(fields[5] as? ByteArray)?.let { parseDeviceSendToApp(it) }
|