@mentra/bluetooth-sdk 0.1.10 → 0.1.11

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.
Files changed (36) hide show
  1. package/README.md +1 -1
  2. package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkModule.kt +39 -16
  3. package/android/src/main/java/com/mentra/bluetoothsdk/Bridge.kt +14 -0
  4. package/android/src/main/java/com/mentra/bluetoothsdk/DeviceManager.kt +22 -3
  5. package/android/src/main/java/com/mentra/bluetoothsdk/DeviceStore.kt +8 -0
  6. package/android/src/main/java/com/mentra/bluetoothsdk/camera/CameraModels.kt +2 -1
  7. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/G2.kt +829 -643
  8. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraNex.kt +40 -64
  9. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/SGCManager.kt +11 -1
  10. package/build/BluetoothSdk.types.d.ts +12 -1
  11. package/build/BluetoothSdk.types.d.ts.map +1 -1
  12. package/build/BluetoothSdk.types.js.map +1 -1
  13. package/build/_private/BluetoothSdkModule.d.ts +1 -0
  14. package/build/_private/BluetoothSdkModule.d.ts.map +1 -1
  15. package/build/_private/BluetoothSdkModule.js +14 -1
  16. package/build/_private/BluetoothSdkModule.js.map +1 -1
  17. package/build/index.d.ts +1 -1
  18. package/build/index.d.ts.map +1 -1
  19. package/build/index.js +2 -0
  20. package/build/index.js.map +1 -1
  21. package/ios/BluetoothSdkModule.swift +3 -0
  22. package/ios/Source/Bridge.swift +10 -0
  23. package/ios/Source/DeviceManager.swift +24 -21
  24. package/ios/Source/DeviceStore.swift +8 -2
  25. package/ios/Source/camera/CameraModels.swift +1 -0
  26. package/ios/Source/sgcs/G1.swift +3 -3
  27. package/ios/Source/sgcs/G2.swift +1022 -640
  28. package/ios/Source/sgcs/Mach1.swift +2 -2
  29. package/ios/Source/sgcs/MentraLive.swift +2 -2
  30. package/ios/Source/sgcs/MentraNex.swift +215 -85
  31. package/ios/Source/sgcs/SGCManager.swift +13 -4
  32. package/ios/Source/sgcs/Simulated.swift +2 -2
  33. package/package.json +1 -1
  34. package/src/BluetoothSdk.types.ts +13 -1
  35. package/src/_private/BluetoothSdkModule.ts +22 -3
  36. package/src/index.ts +3 -0
@@ -27,6 +27,13 @@ import java.io.ByteArrayOutputStream
27
27
  import java.util.TimeZone
28
28
  import java.util.UUID
29
29
  import java.util.regex.Pattern
30
+ import kotlinx.coroutines.CoroutineScope
31
+ import kotlinx.coroutines.Dispatchers
32
+ import kotlinx.coroutines.SupervisorJob
33
+ import kotlinx.coroutines.delay
34
+ import kotlinx.coroutines.launch
35
+ import kotlinx.coroutines.sync.Mutex
36
+ import kotlinx.coroutines.sync.withLock
30
37
 
31
38
  // ---------- G2 Protocol Constants ----------
32
39
 
@@ -49,6 +56,7 @@ private enum class ServiceID(val value: Byte) {
49
56
  DASHBOARD(0x01), // UI_BACKGROUND_DASHBOARD_APP_ID
50
57
  MENU(0x03), // UI_FOREGROUND_MEUN_ID (typo is intentional — matches Even's proto)
51
58
  EVEN_AI(0x07), // UI_FOREGROUND_EVEN_AI_ID
59
+ NAVIGATION(0x08), // UI_BACKGROUND_NAVIGATION_ID (compass/heading lives here)
52
60
  G2_SETTING(0x09), // UI_SETTING_APP_ID
53
61
  GESTURE_CTRL(0x0D), // gesture_ctrl lifecycle signals
54
62
  ONBOARDING(0x10), // UI_ONBOARDING_APP_ID
@@ -69,7 +77,21 @@ private enum class EvenHubCmd(val value: Int) {
69
77
  REBUILD_PAGE(7),
70
78
  SHUTDOWN_PAGE(9),
71
79
  HEARTBEAT(12),
72
- AUDIO_CONTROL(15)
80
+ AUDIO_CONTROL(15),
81
+ IMU_CONTROL(19) // APP_REQUEST_IMU_CTR_PACKET (confirmed via on-device brute-force)
82
+ }
83
+
84
+ // Navigation_Cmd_list from navigation.proto (service 0x08)
85
+ private enum class NavigationCmd(val value: Int) {
86
+ APP_SEND_HEARTBEAT(0), // APP_SEND_HEARTBEAT_CMD
87
+ APP_REQUEST_START_UP(5), // APP_REQUEST_START_UP — begin navigation/compass session
88
+ APP_SEND_BASIC_INFO(7), // APP_SEND_BASIC_INFO
89
+ APP_REQUEST_EXIT(12), // APP_REQUEST_EXIT
90
+ OS_NOTIFY_EXIT(13), // OS_NOTIFY_EXIT
91
+ OS_NOTIFY_REVIEW_CHANGED(14), // OS_NOTIFY_REVIEW_CHANGED
92
+ OS_NOTIFY_COMPASS_CHANGED(15), // OS_NOTIFY_COMPASS_CHANGED — heading update
93
+ OS_NOTIFY_COMPASS_CALIBRATE_START(16), // OS_NOTIFY_COMPASS_CALIBRATE_STRAT (sic)
94
+ OS_NOTIFY_COMPASS_CALIBRATE_COMPLETE(17) // OS_NOTIFY_COMPASS_CALIBRATE_COMPLETE
73
95
  }
74
96
 
75
97
  // EvenHub response command IDs (glasses → phone)
@@ -86,7 +108,8 @@ private enum class OsEventType(val value: Int) {
86
108
  FOREGROUND_ENTER(4),
87
109
  FOREGROUND_EXIT(5),
88
110
  ABNORMAL_EXIT(6),
89
- SYSTEM_EXIT(7);
111
+ SYSTEM_EXIT(7),
112
+ IMU_DATA_REPORT(8); // IMU_DATA_REPORT — Sys_ItemEvent carries imuData
90
113
 
91
114
  companion object {
92
115
  fun fromInt(v: Int): OsEventType? = entries.find { it.value == v }
@@ -465,6 +488,52 @@ private object EvenHubProto {
465
488
  val msg = audioCtrCmd(enable)
466
489
  return evenHubMessage(EvenHubCmd.AUDIO_CONTROL, 18, msg, magicRandom = magicRandom)
467
490
  }
491
+
492
+ // ---------- IMU control ----------
493
+ //
494
+ // Wire format recovered by on-device brute-force (sample magnitude ≈ 1.0 g confirms
495
+ // the decode). Shapes from even_hub_sdk@0.0.10; numeric proto tags confirmed live:
496
+ // EvenHub_Cmd_List IMU command = 19
497
+ // evenhub_main_msg_ctx ImuCtrlCmd slot = field 20
498
+ // ImuCtrlCmd { field 1 = IMU_ReportEn (bool), field 2 = reportFrq (pacing 100…1000) }
499
+ // Report path: cmd=2 (osNotifyEventToApp) → SendDeviceEvent.field13 →
500
+ // Sys_ItemEvent { field 1 = eventType = 8 (IMU_DATA_REPORT),
501
+ // field 3 = imuData = IMU_Report_Data }
502
+ // IMU_Report_Data { field 1 = x, 2 = y, 3 = z } — each a 32-bit float (NOT double),
503
+ // gravity-normalized (|v| ≈ 1 at rest).
504
+ const val IMU_CTRL_SUB_FIELD = 20
505
+
506
+ /** ImuReportPace pacing codes (protocol values, NOT literal Hz). Step 100, 100…1000. */
507
+ const val IMU_PACE_P100 = 100
508
+ const val IMU_PACE_P500 = 500
509
+ const val IMU_PACE_P1000 = 1000
510
+
511
+ /** Build an ImuCtrlCmd sub-message. */
512
+ fun imuCtrlCmd(enable: Boolean, reportFrq: Int): ByteArray {
513
+ val w = ProtobufWriter()
514
+ w.writeInt32Field(1, if (enable) 1 else 0) // IMU_ReportEn
515
+ if (enable) {
516
+ w.writeInt32Field(2, reportFrq) // reportFrq (pacing code 100…1000)
517
+ }
518
+ return w.toByteArray()
519
+ }
520
+
521
+ /**
522
+ * Build a full evenhub_main_msg_ctx that enables/disables IMU reporting. `reportFrq` is an
523
+ * ImuReportPace pacing code; ignored when disabling.
524
+ */
525
+ fun imuControlMessage(
526
+ enable: Boolean,
527
+ reportFrq: Int = IMU_PACE_P100,
528
+ magicRandom: Int = 0
529
+ ): ByteArray {
530
+ val imuMsg = imuCtrlCmd(enable, reportFrq)
531
+ val w = ProtobufWriter()
532
+ w.writeInt32Field(1, EvenHubCmd.IMU_CONTROL.value) // Cmd
533
+ w.writeInt32Field(2, magicRandom) // MagicRandom
534
+ w.writeMessageField(IMU_CTRL_SUB_FIELD, imuMsg) // ImuCtrlCmd slot (field 20)
535
+ return w.toByteArray()
536
+ }
468
537
  }
469
538
 
470
539
  // ---------- DevSettings Auth Protobuf Builders ----------
@@ -667,11 +736,23 @@ private object OnboardingProto {
667
736
  // ---------- EvenAI Protobuf Builders (even_ai.proto, service ID 7) ----------
668
737
 
669
738
  private object EvenAIProto {
739
+ /**
740
+ * EvenAIDataPackage with CONFIG command to toggle Hey Even wakeword.
741
+ * voiceSwitch: 0 = OFF, 1 = ON.
742
+ *
743
+ * Wire format confirmed by sniffing the official app toggling the setting:
744
+ * EvenAIConfig (field 13) = { f1=voiceSwitch, f2=32 }
745
+ * The app OMITS f1 when disabling (proto3 zero) and sends f2=32 (0x20), NOT 80.
746
+ * Observed echoes: ON → 6A04 08 01 10 20 ({f1:1, f2:32})
747
+ * OFF → 6A02 10 20 ({f2:32})
748
+ */
670
749
  fun setHeyEven(magicRandom: Int, enabled: Boolean): ByteArray {
671
750
  // EvenAIConfig
672
751
  val configW = ProtobufWriter()
673
- configW.writeInt32Field(1, if (enabled) 1 else 0) // voiceSwitch
674
- configW.writeInt32Field(2, 80) // streamSpeed (always sent)
752
+ if (enabled) {
753
+ configW.writeInt32Field(1, 1) // voiceSwitch (omitted when off, matching the app)
754
+ }
755
+ configW.writeInt32Field(2, 32) // streamSpeed (always sent, app uses 32)
675
756
 
676
757
  // EvenAIDataPackage
677
758
  val w = ProtobufWriter()
@@ -1226,7 +1307,6 @@ class G2 : SGCManager() {
1226
1307
  private val EVEN_HUB_QUEUE_TICK_MS = 100L
1227
1308
  private var startupPageCreated: Boolean = false
1228
1309
  private var pageCreated: Boolean = false
1229
- private var pageHasTextContainer: Boolean = false
1230
1310
  private var currentTextContent: String = ""
1231
1311
  private var textContainerID: Int = 1
1232
1312
  private var imageSessionCounter: Int = 0
@@ -1242,16 +1322,29 @@ class G2 : SGCManager() {
1242
1322
  private var dashboardMenuItems: MutableList<MenuProto.MenuItem> = mutableListOf()
1243
1323
  private var activeMenuAppId: Int? = null
1244
1324
  private var lastClickTimestamp: Long? = null
1325
+ private var lastEvenHubResponseTimestamp: Long? = null
1245
1326
  private var lastMenuSelectTimestamp: Long? = null
1246
1327
  private var lastGestureCtrlTimestamp: Long? = null
1247
1328
 
1329
+ /**
1330
+ * Serializes all display mutations (text/bitmap/clear/rebuild). Each public display entry point
1331
+ * launches on [displayScope] and runs its body under [displayMutex] so page shutdown/create and
1332
+ * fragment sends can never interleave with another update on the glasses.
1333
+ */
1334
+ private val displayScope = CoroutineScope(Dispatchers.Main + SupervisorJob())
1335
+ private val displayMutex = Mutex()
1336
+
1248
1337
  /** A tracked image container on the current page. Keyed by its rect for reuse. */
1249
1338
  private data class ImgContainer(
1250
1339
  val id: Int,
1251
1340
  val x: Int,
1252
1341
  val y: Int,
1253
1342
  val width: Int,
1254
- val height: Int
1343
+ val height: Int,
1344
+ // The converted BMP cached so the container can be re-sent on a page rebuild.
1345
+ // NOTE: this ByteArray makes the auto-generated equals/hashCode unreliable, but the
1346
+ // class is only ever compared via matches()/id, so that is never relied upon.
1347
+ var bmpData: ByteArray
1255
1348
  ) {
1256
1349
  val name: String
1257
1350
  get() = "img-$id"
@@ -1260,18 +1353,65 @@ class G2 : SGCManager() {
1260
1353
  this.x == x && this.y == y && this.width == width && this.height == height
1261
1354
  }
1262
1355
 
1356
+ /** A tracked text container on the current page. Keyed by its rect for reuse. */
1357
+ private data class TextContainer(
1358
+ val id: Int,
1359
+ val x: Int,
1360
+ val y: Int,
1361
+ val width: Int,
1362
+ val height: Int,
1363
+ var content: String,
1364
+ val borderWidth: Int,
1365
+ val borderColor: Int,
1366
+ val borderRadius: Int,
1367
+ val paddingLength: Int
1368
+ ) {
1369
+ val name: String
1370
+ get() = "text-$id"
1371
+
1372
+ fun matches(
1373
+ x: Int,
1374
+ y: Int,
1375
+ width: Int,
1376
+ height: Int,
1377
+ borderWidth: Int,
1378
+ borderColor: Int,
1379
+ borderRadius: Int,
1380
+ paddingLength: Int
1381
+ ): Boolean =
1382
+ this.x == x &&
1383
+ this.y == y &&
1384
+ this.width == width &&
1385
+ this.height == height &&
1386
+ this.borderWidth == borderWidth &&
1387
+ this.borderColor == borderColor &&
1388
+ this.borderRadius == borderRadius &&
1389
+ this.paddingLength == paddingLength
1390
+ }
1391
+
1263
1392
  /**
1264
1393
  * Live list of image containers on the page, ordered oldest→newest (for LRU eviction). The page
1265
1394
  * may hold at most 4 image containers (IDs from the pool below).
1266
1395
  */
1267
1396
  private val imageContainers: MutableList<ImgContainer> = mutableListOf()
1397
+ private val textContainers: MutableList<TextContainer> = mutableListOf()
1268
1398
  /** Fixed pool of container IDs the page protocol expects. */
1269
1399
  private val imageContainerIDPool: List<Int> = listOf(10, 11, 12, 13)
1270
- /** Default container seeded into every fresh page: 100x100 in the top-left. */
1271
- private val defaultImgX = 0
1272
- private val defaultImgY = 0
1273
- private val defaultImgWidth = 100
1400
+ private val textContainerIDPool: List<Int> = listOf(1, 2, 3, 4, 5, 6)
1401
+ /** Default container seeded into every fresh page: 200x100 centered at 188,44. */
1402
+ private val defaultImgX = 188
1403
+ private val defaultImgY = 44
1404
+ private val defaultImgWidth = 200
1274
1405
  private val defaultImgHeight = 100
1406
+ /** Default text container: full-screen 576x288 with a 4px padding. */
1407
+ private val defaultTextX = 0
1408
+ private val defaultTextY = 0
1409
+ private val defaultTextWidth = 576
1410
+ private val defaultTextHeight = 288
1411
+ private val defaultTextBorderWidth = 0
1412
+ private val defaultTextBorderColor = 0
1413
+ private val defaultTextBorderRadius = 0
1414
+ private val defaultTextPaddingLength = 4
1275
1415
 
1276
1416
  // Battery state
1277
1417
  private var _batteryLevel: Int = -1
@@ -1329,6 +1469,7 @@ class G2 : SGCManager() {
1329
1469
  left: Boolean = false,
1330
1470
  right: Boolean = true
1331
1471
  ) {
1472
+ // Bridge.log("G2: sendToGlasses() - sending ${packets.size} packets first byte: ${packets[0][0]}")
1332
1473
  if (packets.isEmpty()) return
1333
1474
  // Single-packet sends (the common case for text/settings) go straight through.
1334
1475
  if (packets.size == 1) {
@@ -1368,6 +1509,16 @@ class G2 : SGCManager() {
1368
1509
  sendToGlasses(packets, left = left, right = right)
1369
1510
  }
1370
1511
 
1512
+ private fun sendNavigationCommand(payload: ByteArray) {
1513
+ val packets =
1514
+ sendManager.buildPackets(
1515
+ serviceId = ServiceID.NAVIGATION.value,
1516
+ payload = payload,
1517
+ reserveFlag = true
1518
+ )
1519
+ sendToGlasses(packets)
1520
+ }
1521
+
1371
1522
  private fun sendG2SettingCommand(payload: ByteArray) {
1372
1523
  val packets =
1373
1524
  sendManager.buildPackets(
@@ -1440,7 +1591,7 @@ class G2 : SGCManager() {
1440
1591
 
1441
1592
  // ---------- Authentication Sequence ----------
1442
1593
 
1443
- private fun runAuthSequence() {
1594
+ private suspend fun runAuthSequence() {
1444
1595
  Bridge.log("G2: Running auth sequence")
1445
1596
 
1446
1597
  // Auth to left side
@@ -1450,443 +1601,113 @@ class G2 : SGCManager() {
1450
1601
  }
1451
1602
 
1452
1603
  // Small delay then auth right + pipe role change + time sync
1453
- mainHandler.postDelayed(
1454
- {
1455
- val authR = DevSettingsProto.authCmd(sendManager.nextMagicRandom())
1456
- sendDevSettingsCommand(authR, left = false, right = true)
1457
-
1458
- mainHandler.postDelayed(
1459
- {
1460
- val roleChange =
1461
- DevSettingsProto.pipeRoleChange(
1462
- sendManager.nextMagicRandom()
1463
- )
1464
- sendDevSettingsCommand(roleChange, left = false, right = true)
1465
-
1466
- mainHandler.postDelayed(
1467
- {
1468
- val timeSync =
1469
- DevSettingsProto.timeSync(
1470
- sendManager.nextMagicRandom()
1471
- )
1472
- sendDevSettingsCommand(timeSync)
1473
-
1474
- // Skip onboarding on connect
1475
- mainHandler.postDelayed(
1476
- {
1477
- val onboarding =
1478
- OnboardingProto.skipOnboarding(
1479
- sendManager
1480
- .nextMagicRandom()
1481
- )
1482
- sendOnboardingCommand(onboarding)
1483
- Bridge.log(
1484
- "G2: Sent onboarding skip (FINISH)"
1485
- )
1486
-
1487
- // Disable "Hey Even" wakeword on connect
1488
- val heyEvenOff =
1489
- EvenAIProto.setHeyEven(
1490
- sendManager
1491
- .nextMagicRandom(),
1492
- false
1493
- )
1494
- sendEvenAICommand(heyEvenOff)
1495
- Bridge.log("G2: Disabled Hey Even wakeword")
1496
-
1497
- // Replicate Even app's full init sequence
1498
- // for menu selection support:
1499
-
1500
- // 0. Universe settings (g2_setting cmd=1
1501
- // field3 with field9=universe settings)
1502
- val univW = ProtobufWriter()
1503
- univW.writeInt32Field(
1504
- 1,
1505
- 1
1506
- ) // DeviceReceiveInfo
1507
- univW.writeInt32Field(
1508
- 2,
1509
- sendManager.nextMagicRandom()
1510
- )
1511
- univW.writeMessageField(
1512
- 3,
1513
- byteArrayOf(
1514
- 0x4A,
1515
- 0x0A, // field 9, length 10
1516
- 0x08,
1517
- 0x00, // unitFormat=0
1518
- 0x10,
1519
- 0x00, // distanceUnit=0
1520
- 0x18,
1521
- dashboardHalfDayFormat().toByte(),
1522
- // timeFormat / halfDayFormat
1523
- 0x20,
1524
- 0x00, // dateFormat=0
1525
- 0x28,
1526
- dashboardTemperatureUnit().toByte(),
1527
- // temperatureUnit
1528
- )
1529
- )
1530
- sendG2SettingCommand(univW.toByteArray())
1531
-
1532
- // 1. gesture_ctrl init (field1=0,
1533
- // field2=magicRandom)
1534
- val gestureInitW = ProtobufWriter()
1535
- gestureInitW.writeInt32Field(1, 0)
1536
- gestureInitW.writeInt32Field(
1537
- 2,
1538
- sendManager.nextMagicRandom()
1539
- )
1540
- sendGestureCtrlCommand(
1541
- gestureInitW.toByteArray()
1542
- )
1543
-
1544
- // 2. ui_setting_app (0x0C) — query
1545
- val uiSettW = ProtobufWriter()
1546
- uiSettW.writeInt32Field(
1547
- 1,
1548
- 2
1549
- ) // cmd = DeviceReceiveRequest
1550
- uiSettW.writeInt32Field(
1551
- 2,
1552
- sendManager.nextMagicRandom()
1553
- )
1554
- uiSettW.writeMessageField(
1555
- 4,
1556
- byteArrayOf(0x08, 0x01, 0x10, 0x00)
1557
- ) // {1:1, 2:0}
1558
- sendToGlasses(
1559
- sendManager.buildPackets(
1560
- serviceId = 0x0C,
1561
- payload =
1562
- uiSettW.toByteArray(),
1563
- reserveFlag = true
1564
- )
1565
- )
1566
-
1567
- // 3. teleprompter (0x10) — config (cmd=1,
1568
- // field3={1:4})
1569
- val teleW = ProtobufWriter()
1570
- teleW.writeInt32Field(1, 1)
1571
- teleW.writeInt32Field(
1572
- 2,
1573
- sendManager.nextMagicRandom()
1574
- )
1575
- teleW.writeMessageField(
1576
- 3,
1577
- byteArrayOf(0x08, 0x04)
1578
- ) // {1:4}
1579
- sendToGlasses(
1580
- sendManager.buildPackets(
1581
- serviceId = 0x10,
1582
- payload =
1583
- teleW.toByteArray(),
1584
- reserveFlag = true
1585
- )
1586
- )
1587
-
1588
- // 4. EvenHub CTRL on service 0x81 (cmd=1,
1589
- // empty field3)
1590
- val ehCtrlW = ProtobufWriter()
1591
- ehCtrlW.writeInt32Field(1, 1)
1592
- ehCtrlW.writeInt32Field(
1593
- 2,
1594
- sendManager.nextMagicRandom()
1595
- )
1596
- ehCtrlW.writeMessageField(3, ByteArray(0))
1597
- sendEvenHubCtrlCommand(
1598
- ehCtrlW.toByteArray()
1599
- )
1600
-
1601
- // 5. calendar (0x04) — config
1602
- val calW = ProtobufWriter()
1603
- calW.writeInt32Field(1, 1)
1604
- calW.writeInt32Field(
1605
- 2,
1606
- sendManager.nextMagicRandom()
1607
- )
1608
- calW.writeMessageField(
1609
- 3,
1610
- byteArrayOf(
1611
- 0x08,
1612
- 0x01,
1613
- 0x10,
1614
- 0x01,
1615
- 0x18,
1616
- 0x05,
1617
- 0x28,
1618
- 0x01
1619
- )
1620
- )
1621
- sendToGlasses(
1622
- sendManager.buildPackets(
1623
- serviceId = 0x04,
1624
- payload =
1625
- calW.toByteArray(),
1626
- reserveFlag = true
1627
- )
1628
- )
1629
-
1630
- // 6. Dashboard init (0x01) — display
1631
- // settings
1632
- val dashDisplayW = ProtobufWriter()
1633
- dashDisplayW.writeInt32Field(
1634
- 1,
1635
- 4
1636
- ) // displayMode
1637
- dashDisplayW.writeInt32Field(
1638
- 2,
1639
- 3
1640
- ) // statusDisplayCount
1641
- dashDisplayW.writeMessageField(
1642
- 3,
1643
- byteArrayOf(1, 2, 3)
1644
- ) // statusDisplayOrder
1645
- dashDisplayW.writeInt32Field(
1646
- 4,
1647
- 4
1648
- ) // widgetDisplayCount
1649
- dashDisplayW.writeMessageField(
1650
- 5,
1651
- byteArrayOf(1, 3, 2, 2)
1652
- ) // widgetDisplayOrder
1653
- dashDisplayW.writeInt32Field(
1654
- 6,
1655
- dashboardHalfDayFormat()
1656
- ) // halfDayFormat
1657
- dashDisplayW.writeInt32Field(
1658
- 7,
1659
- dashboardTemperatureUnit()
1660
- ) // temperatureUnit
1661
-
1662
- val dashRecvW = ProtobufWriter()
1663
- dashRecvW.writeMessageField(
1664
- 2,
1665
- dashDisplayW.toByteArray()
1666
- )
1667
-
1668
- val dashPkgW = ProtobufWriter()
1669
- dashPkgW.writeInt32Field(
1670
- 1,
1671
- 2
1672
- ) // Dashboard_Receive
1673
- dashPkgW.writeInt32Field(
1674
- 2,
1675
- sendManager.nextMagicRandom()
1676
- )
1677
- dashPkgW.writeMessageField(
1678
- 4,
1679
- dashRecvW.toByteArray()
1680
- )
1681
- sendDashboardCommand(dashPkgW.toByteArray())
1682
-
1683
- // 7. Dashboard REQUEST_NEWS_INFO (cmd=5,
1684
- // field7={1:1})
1685
- val dashNewsReqW = ProtobufWriter()
1686
- dashNewsReqW.writeInt32Field(
1687
- 1,
1688
- 5
1689
- ) // REQUEST_NEWS_INFO
1690
- dashNewsReqW.writeInt32Field(
1691
- 2,
1692
- sendManager.nextMagicRandom()
1693
- )
1694
- dashNewsReqW.writeMessageField(
1695
- 7,
1696
- byteArrayOf(0x08, 0x01)
1697
- ) // {1:1}
1698
- sendDashboardCommand(
1699
- dashNewsReqW.toByteArray()
1700
- )
1701
-
1702
- // 8. Gesture control list via g2_setting
1703
- val gestListW = ProtobufWriter()
1704
- gestListW.writeInt32Field(
1705
- 1,
1706
- 1
1707
- ) // DeviceReceiveInfo
1708
- gestListW.writeInt32Field(
1709
- 2,
1710
- sendManager.nextMagicRandom()
1711
- )
1712
- // field 3 with field 10
1713
- // (gestureControlList): 3 items, all
1714
- // app_unable
1715
- val gestureCtrlPayload =
1716
- byteArrayOf(
1717
- 0x52,
1718
- 0x18, // field 10, length 24
1719
- 0x0A,
1720
- 0x06,
1721
- 0x08,
1722
- 0x00,
1723
- 0x10,
1724
- 0x00,
1725
- 0x18,
1726
- 0x00, // item 1
1727
- 0x0A,
1728
- 0x06,
1729
- 0x08,
1730
- 0x00,
1731
- 0x10,
1732
- 0x01,
1733
- 0x18,
1734
- 0x00, // item 2
1735
- 0x0A,
1736
- 0x06,
1737
- 0x08,
1738
- 0x00,
1739
- 0x10,
1740
- 0x02,
1741
- 0x18,
1742
- 0x00 // item 3
1743
- )
1744
- gestListW.writeMessageField(
1745
- 3,
1746
- gestureCtrlPayload
1747
- )
1748
- sendG2SettingCommand(
1749
- gestListW.toByteArray()
1750
- )
1751
-
1752
- // 9. Dashboard APP_REQUEST_NEWS_INFO
1753
- // (cmd=7, field9={1:1})
1754
- val dashAppNewsW = ProtobufWriter()
1755
- dashAppNewsW.writeInt32Field(
1756
- 1,
1757
- 7
1758
- ) // APP_REQUEST_NEWS_INFO
1759
- dashAppNewsW.writeInt32Field(
1760
- 2,
1761
- sendManager.nextMagicRandom()
1762
- )
1763
- dashAppNewsW.writeMessageField(
1764
- 9,
1765
- byteArrayOf(0x08, 0x01)
1766
- ) // {1:1}
1767
- sendDashboardCommand(
1768
- dashAppNewsW.toByteArray()
1769
- )
1770
-
1771
- Bridge.log(
1772
- "G2: Sent full Even-compatible init sequence"
1773
- )
1774
- },
1775
- 200
1776
- )
1777
-
1778
- // Start heartbeats after auth
1779
- startHeartbeats()
1780
-
1781
- // Mark as ready and request device info
1782
- mainHandler.postDelayed(
1783
- {
1784
- reconnectionManager.stop()
1785
- Bridge.log(
1786
- "G2: Auth sequence complete, glasses ready"
1787
- )
1788
-
1789
- // Set device_name so DeviceManager can save
1790
- // it for reconnection
1791
- val peripheralName =
1792
- rightGatt?.device?.name
1793
- ?: leftGatt?.device?.name
1794
- val serialNumber =
1795
- peripheralName?.let {
1796
- deviceNameToSerialNumber[it]
1797
- }
1798
- if (serialNumber != null) {
1799
- DeviceStore.apply(
1800
- "bluetooth",
1801
- "device_name",
1802
- serialNumber
1803
- )
1804
- Bridge.log(
1805
- "G2: Set device_name to $serialNumber"
1806
- )
1807
- }
1808
-
1809
- // Set bluetooth name and device model for
1810
- // Device Info page
1811
- val btName =
1812
- rightGatt?.device?.name
1813
- ?: leftGatt?.device?.name
1814
- ?: ""
1815
- DeviceStore.apply(
1816
- "glasses",
1817
- "bluetoothName",
1818
- btName
1819
- )
1820
- DeviceStore.apply(
1821
- "glasses",
1822
- "deviceModel",
1823
- DeviceTypes.G2
1824
- )
1825
-
1826
- setFullyConnected()
1827
-
1828
- // Connect a controller if we have one
1829
- connectController()
1830
-
1831
- // Query version + battery info from glasses
1832
- requestDeviceInfo()
1833
-
1834
- sendMenuApps()
1835
- sendStoredCalendarEvents()
1836
- },
1837
- 500
1838
- )
1839
- },
1840
- 200
1841
- )
1842
- },
1843
- 200
1844
- )
1845
- },
1846
- 200
1604
+ delay(200)
1605
+ val authR = DevSettingsProto.authCmd(sendManager.nextMagicRandom())
1606
+ sendDevSettingsCommand(authR, left = false, right = true)
1607
+
1608
+ delay(200)
1609
+ val roleChange = DevSettingsProto.pipeRoleChange(sendManager.nextMagicRandom())
1610
+ sendDevSettingsCommand(roleChange, left = false, right = true)
1611
+
1612
+ delay(200)
1613
+ val timeSync = DevSettingsProto.timeSync(sendManager.nextMagicRandom())
1614
+ sendDevSettingsCommand(timeSync)
1615
+
1616
+ // Skip onboarding on connect
1617
+ delay(200)
1618
+ val onboarding = OnboardingProto.skipOnboarding(sendManager.nextMagicRandom())
1619
+ sendOnboardingCommand(onboarding)
1620
+ Bridge.log("G2: Sent onboarding skip (FINISH)")
1621
+
1622
+ // 1. gesture_ctrl init (field1=0, field2=magicRandom)
1623
+ val gestureInitW = ProtobufWriter()
1624
+ gestureInitW.writeInt32Field(1, 0)
1625
+ gestureInitW.writeInt32Field(2, sendManager.nextMagicRandom())
1626
+ sendGestureCtrlCommand(gestureInitW.toByteArray())
1627
+
1628
+ // 2. ui_setting_app (0x0C) — query (cmd=2, field4={settingInfoType=1, autoBrightnessLevel=0})
1629
+ val uiSettW = ProtobufWriter()
1630
+ uiSettW.writeInt32Field(1, 2) // cmd = DeviceReceiveRequest
1631
+ uiSettW.writeInt32Field(2, sendManager.nextMagicRandom())
1632
+ uiSettW.writeMessageField(4, byteArrayOf(0x08, 0x01, 0x10, 0x00)) // {1:1, 2:0}
1633
+ sendToGlasses(
1634
+ sendManager.buildPackets(
1635
+ serviceId = 0x0C,
1636
+ payload = uiSettW.toByteArray(),
1637
+ reserveFlag = true
1638
+ )
1847
1639
  )
1848
- }
1849
1640
 
1850
- private fun runDashboardSequence() {
1851
- Bridge.log("G2: Running dashboard sequence")
1641
+ // 6. Dashboard init (0x01) — display settings
1642
+ // halfDayFormat: 1 = 12h, 0 = 24h
1643
+ // temperatureUnit: 1 = Celsius (metric), 2 = Fahrenheit (imperial)
1644
+ val dashDisplayW = ProtobufWriter()
1645
+ dashDisplayW.writeInt32Field(1, 4) // displayMode
1646
+ dashDisplayW.writeInt32Field(2, 3) // statusDisplayCount
1647
+ dashDisplayW.writeMessageField(3, byteArrayOf(1, 2, 3)) // statusDisplayOrder
1648
+ dashDisplayW.writeInt32Field(4, 4) // widgetDisplayCount
1649
+ // WidgetType: 1=News, 2=Stock, 3=Schedule, 4=Quicklist, 5=Health
1650
+ dashDisplayW.writeMessageField(
1651
+ 5,
1652
+ byteArrayOf(3, 1, 2, 4, 5)
1653
+ ) // widgetDisplayOrder: Schedule, News, Stock, Quicklist
1654
+ dashDisplayW.writeInt32Field(6, dashboardHalfDayFormat()) // halfDayFormat
1655
+ dashDisplayW.writeInt32Field(7, dashboardTemperatureUnit()) // temperatureUnit
1852
1656
 
1853
- // Send the shutdown command to the glasses
1854
- val msg = EvenHubProto.shutdownMessage()
1855
- sendEvenHubCommand(msg)
1856
- pageCreated = false
1857
- currentTextContent = ""
1858
-
1859
- mainHandler.postDelayed(
1860
- {
1861
- // 1. gesture_ctrl init (field1=0, field2=magicRandom)
1862
- val gestureInitW = ProtobufWriter()
1863
- gestureInitW.writeInt32Field(1, 0)
1864
- gestureInitW.writeInt32Field(2, sendManager.nextMagicRandom())
1865
- sendGestureCtrlCommand(gestureInitW.toByteArray())
1866
-
1867
- // 6. Dashboard init (0x01) — display settings
1868
- val dashDisplayW = ProtobufWriter()
1869
- dashDisplayW.writeInt32Field(1, 4) // displayMode
1870
- dashDisplayW.writeInt32Field(2, 3) // statusDisplayCount
1871
- dashDisplayW.writeMessageField(3, byteArrayOf(1, 2, 3)) // statusDisplayOrder
1872
- dashDisplayW.writeInt32Field(4, 4) // widgetDisplayCount
1873
- dashDisplayW.writeMessageField(5, byteArrayOf(1, 3, 2, 2)) // widgetDisplayOrder
1874
- dashDisplayW.writeInt32Field(6, dashboardHalfDayFormat()) // halfDayFormat
1875
- dashDisplayW.writeInt32Field(7, dashboardTemperatureUnit()) // temperatureUnit
1876
-
1877
- val dashRecvW = ProtobufWriter()
1878
- dashRecvW.writeMessageField(2, dashDisplayW.toByteArray())
1879
-
1880
- val dashPkgW = ProtobufWriter()
1881
- dashPkgW.writeInt32Field(1, 2) // Dashboard_Receive
1882
- dashPkgW.writeInt32Field(2, sendManager.nextMagicRandom())
1883
- dashPkgW.writeMessageField(4, dashRecvW.toByteArray())
1884
- sendDashboardCommand(dashPkgW.toByteArray())
1885
-
1886
- Bridge.log("G2: Sent full Even-compatible init sequence")
1887
- },
1888
- 1000
1889
- )
1657
+ val dashRecvW = ProtobufWriter()
1658
+ dashRecvW.writeMessageField(2, dashDisplayW.toByteArray())
1659
+
1660
+ val dashPkgW = ProtobufWriter()
1661
+ dashPkgW.writeInt32Field(1, 2) // Dashboard_Receive
1662
+ dashPkgW.writeInt32Field(2, sendManager.nextMagicRandom())
1663
+ dashPkgW.writeMessageField(4, dashRecvW.toByteArray())
1664
+ sendDashboardCommand(dashPkgW.toByteArray())
1665
+
1666
+ // Disable "Hey Even" wakeword on connect
1667
+ val heyEvenOff = EvenAIProto.setHeyEven(sendManager.nextMagicRandom(), false)
1668
+ sendEvenAICommand(heyEvenOff)
1669
+ Bridge.log("G2: Disabled Hey Even wakeword")
1670
+
1671
+ Bridge.log("G2: Sent full Even-compatible init sequence")
1672
+
1673
+ // Start heartbeats after auth
1674
+ startHeartbeats()
1675
+
1676
+ reconnectionManager.stop()
1677
+ Bridge.log("G2: Auth sequence complete, glasses ready")
1678
+
1679
+ // Set device_name so DeviceManager can save it for reconnection
1680
+ val peripheralName = rightGatt?.device?.name ?: leftGatt?.device?.name
1681
+ val serialNumber = peripheralName?.let { deviceNameToSerialNumber[it] }
1682
+ if (serialNumber != null) {
1683
+ DeviceStore.apply("bluetooth", "device_name", serialNumber)
1684
+ Bridge.log("G2: Set device_name to $serialNumber")
1685
+ }
1686
+
1687
+ // Set bluetooth name and device model for Device Info page
1688
+ val btName = rightGatt?.device?.name ?: leftGatt?.device?.name ?: ""
1689
+ DeviceStore.apply("glasses", "bluetoothName", btName)
1690
+ DeviceStore.apply("glasses", "deviceModel", DeviceTypes.G2)
1691
+
1692
+ setFullyConnected()
1693
+
1694
+ // Connect a controller if we have one
1695
+ connectController()
1696
+
1697
+ // Query version + battery info from glasses
1698
+ requestDeviceInfo()
1699
+
1700
+ sendMenuApps()
1701
+ sendStoredCalendarEvents()
1702
+
1703
+ // Re-apply the IMU preference: the store only pushes imu_enabled to the glasses when
1704
+ // the value changes, so after a reconnect an already-on IMU would otherwise stay off
1705
+ // (accel_event stops) until the user toggles the setting again.
1706
+ val imuEnabled = DeviceStore.get("bluetooth", "imu_enabled") as? Boolean ?: false
1707
+ if (imuEnabled) {
1708
+ Bridge.log("G2: re-applying imu_enabled=true after connect")
1709
+ setImuEnabled(true)
1710
+ }
1890
1711
  }
1891
1712
 
1892
1713
  private fun dashboardHalfDayFormat(): Int {
@@ -1922,18 +1743,18 @@ class G2 : SGCManager() {
1922
1743
  // ---------- Heartbeats ----------
1923
1744
 
1924
1745
  private fun startHeartbeats() {
1925
- // EvenHub heartbeat every 5 seconds
1746
+ // EvenHub heartbeat every 10 seconds
1926
1747
  stopHeartbeats()
1927
1748
 
1928
1749
  val hbRunnable =
1929
1750
  object : Runnable {
1930
1751
  override fun run() {
1931
1752
  sendEvenHubHeartbeat()
1932
- mainHandler.postDelayed(this, 5000)
1753
+ mainHandler.postDelayed(this, 10000)
1933
1754
  }
1934
1755
  }
1935
1756
  heartbeatRunnable = hbRunnable
1936
- mainHandler.postDelayed(hbRunnable, 5000)
1757
+ mainHandler.postDelayed(hbRunnable, 10000)
1937
1758
 
1938
1759
  // DevSettings heartbeat every 5 seconds
1939
1760
  val dsRunnable =
@@ -2018,35 +1839,130 @@ class G2 : SGCManager() {
2018
1839
  // ---------- SGCManager: Display Control ----------
2019
1840
 
2020
1841
  override fun sendTextWall(text: String) {
2021
- // Bridge.log("G2: sendTextWall(${text.take(10)}...)")
1842
+ displayScope.launch {
1843
+ displayMutex.withLock {
1844
+ sendText(
1845
+ text,
1846
+ x = defaultTextX,
1847
+ y = defaultTextY,
1848
+ width = defaultTextWidth,
1849
+ height = defaultTextHeight,
1850
+ borderWidth = defaultTextBorderWidth,
1851
+ borderColor = defaultTextBorderColor,
1852
+ borderRadius = defaultTextBorderRadius,
1853
+ paddingLength = defaultTextPaddingLength
1854
+ )
1855
+ }
1856
+ }
1857
+ }
1858
+
1859
+ private suspend fun sendText(
1860
+ text: String,
1861
+ x: Int? = null,
1862
+ y: Int? = null,
1863
+ width: Int? = null,
1864
+ height: Int? = null,
1865
+ borderWidth: Int? = null,
1866
+ borderColor: Int? = null,
1867
+ borderRadius: Int? = null,
1868
+ paddingLength: Int? = null
1869
+ ) {
1870
+ // Bridge.log("G2: sendTextWall(${text.take(50)}...)")
2022
1871
 
2023
1872
  // ignore events while the ER dashboard is open:
2024
- val useNativeDashboard = DeviceStore.get("bluetooth", "use_native_dashboard") as? Boolean ?: false
1873
+ val useNativeDashboard =
1874
+ DeviceStore.get("bluetooth", "use_native_dashboard") as? Boolean ?: false
2025
1875
  if (useNativeDashboard && dashboardShowing > 0) {
2026
1876
  return
2027
1877
  }
2028
1878
 
2029
- if (text.isEmpty()) {
2030
- clearDisplay()
2031
- return
2032
- }
1879
+ val rx = x ?: defaultTextX
1880
+ val ry = y ?: defaultTextY
1881
+ val rw = width ?: defaultTextWidth
1882
+ val rh = height ?: defaultTextHeight
1883
+ val rBorderWidth = defaultTextBorderWidth
1884
+ val rBorderColor = defaultTextBorderColor
1885
+ val rBorderRadius = defaultTextBorderRadius
1886
+ val rPaddingLength = defaultTextPaddingLength
1887
+ val content = if (text.isEmpty()) " " else text
2033
1888
 
2034
- if (!pageCreated || !pageHasTextContainer) {
2035
- createPageWithText(text)
2036
- } else {
2037
- updateText(text)
1889
+ // Reuse an existing container if the rect matches exactly; otherwise add a new one.
1890
+ val container: TextContainer
1891
+ val existingIndex =
1892
+ textContainers.indexOfFirst {
1893
+ it.matches(
1894
+ rx,
1895
+ ry,
1896
+ rw,
1897
+ rh,
1898
+ rBorderWidth,
1899
+ rBorderColor,
1900
+ rBorderRadius,
1901
+ rPaddingLength
1902
+ )
1903
+ }
1904
+ if (existingIndex >= 0) {
1905
+ textContainers[existingIndex].content = content
1906
+ container = textContainers[existingIndex]
1907
+ Bridge.log("G2: sendText() - reusing container ${container.id} for rect $rx,$ry ${rw}x$rh")
1908
+ if (!pageCreated) {
1909
+ rebuildPage()
1910
+ return
1911
+ }
1912
+ // update the text container:
1913
+ val msg =
1914
+ EvenHubProto.updateTextMessage(
1915
+ containerID = container.id,
1916
+ contentOffset = 0,
1917
+ contentLength = container.content.toByteArray(Charsets.UTF_8).size,
1918
+ content = container.content
1919
+ )
1920
+ sendEvenHubCommand(msg)
1921
+ return
2038
1922
  }
1923
+ container =
1924
+ addTextContainer(
1925
+ rx,
1926
+ ry,
1927
+ rw,
1928
+ rh,
1929
+ content,
1930
+ rBorderWidth,
1931
+ rBorderColor,
1932
+ rBorderRadius,
1933
+ rPaddingLength
1934
+ )
1935
+ Bridge.log("G2: sendText() - added text container ${container.id} for rect $rx,$ry ${rw}x$rh, rebuilding page")
1936
+ rebuildPage()
2039
1937
  }
2040
1938
 
2041
1939
  override fun sendDoubleTextWall(top: String, bottom: String) {
2042
- val combined = "$top\n$bottom"
1940
+ Bridge.log("G2: sendDoubleTextWall() - top: $top, bottom: $bottom")
1941
+ // G2 doesn't have native double text wall, combine them
1942
+ val combined = "$top\n\n$bottom"
2043
1943
  sendTextWall(combined)
2044
1944
  }
2045
1945
 
2046
1946
  override fun clearDisplay() {
2047
1947
  Bridge.log("G2: clearDisplay()")
2048
- if (pageCreated) {
2049
- sendTextWall(" ")
1948
+ displayScope.launch {
1949
+ displayMutex.withLock {
1950
+ // reset the content of all text containers to empty:
1951
+ for (i in textContainers.indices) {
1952
+ textContainers[i].content = " "
1953
+ }
1954
+ for (i in imageContainers.indices) {
1955
+ imageContainers[i].bmpData = ByteArray(0)
1956
+ }
1957
+ // shutdown the page and then recreate the containers without the content.
1958
+ // Reset pageCreated so the recreate below issues a fresh createPageMessage rather
1959
+ // than a rebuildPageMessage that no longer matches the torn-down page.
1960
+ val msg = EvenHubProto.shutdownMessage()
1961
+ sendEvenHubCommand(msg)
1962
+ pageCreated = false
1963
+ createPageWithContainers()
1964
+ restartMicIfAlreadyEnabled()
1965
+ }
2050
1966
  }
2051
1967
  }
2052
1968
 
@@ -2068,13 +1984,16 @@ class G2 : SGCManager() {
2068
1984
  width: Int?,
2069
1985
  height: Int?
2070
1986
  ): Boolean {
2071
- currentBitmapBase64 = base64ImageData
2072
- currentTextContent = ""
2073
-
2074
1987
  val rx = x ?: defaultImgX
2075
1988
  val ry = y ?: defaultImgY
2076
1989
  val rw = width ?: defaultImgWidth
2077
1990
  val rh = height ?: defaultImgHeight
1991
+
1992
+ // ignore events while the ER dashboard is open:
1993
+ val useNativeDashboard = DeviceStore.get("bluetooth", "use_native_dashboard") as? Boolean ?: false
1994
+ if (useNativeDashboard && dashboardShowing > 0) {
1995
+ return false
1996
+ }
2078
1997
 
2079
1998
  val rawData =
2080
1999
  Base64.decode(base64ImageData, Base64.DEFAULT)
@@ -2083,54 +2002,38 @@ class G2 : SGCManager() {
2083
2002
  return false
2084
2003
  }
2085
2004
 
2086
- // Create the startup page lazily, seeded with the default top-left container.
2087
- val freshPage = !startupPageCreated
2088
- if (freshPage) {
2089
- imageContainers.clear()
2090
- imageContainers.add(
2091
- ImgContainer(
2092
- id = imageContainerIDPool[0],
2093
- x = defaultImgX,
2094
- y = defaultImgY,
2095
- width = defaultImgWidth,
2096
- height = defaultImgHeight
2097
- )
2098
- )
2099
- createPageWithText("")
2100
- Bridge.log("G2: displayBitmap() - startup page created")
2101
- }
2102
-
2103
- // Reuse an existing container if the rect matches exactly; otherwise add a new one.
2104
- val existing = imageContainers.firstOrNull { it.matches(rx, ry, rw, rh) }
2105
- val container: ImgContainer
2106
- val needsRebuild: Boolean
2107
- if (existing != null) {
2108
- container = existing
2109
- needsRebuild = false
2110
- Bridge.log("G2: displayBitmap() - reusing container ${existing.id} for rect $rx,$ry ${rw}x$rh")
2111
- } else {
2112
- container = addImageContainer(rx, ry, rw, rh)
2113
- needsRebuild = true
2114
- Bridge.log("G2: displayBitmap() - added container ${container.id} for rect $rx,$ry ${rw}x$rh, rebuilding page")
2115
- rebuildPage()
2116
- }
2117
-
2118
2005
  val bmpData =
2119
- convertToG2Bmp(rawData, containerWidth = container.width, containerHeight = container.height)
2006
+ convertToG2Bmp(rawData, containerWidth = rw, containerHeight = rh)
2120
2007
  ?: run {
2121
2008
  Bridge.log("G2: displayBitmap() - failed to convert image to BMP")
2122
2009
  return false
2123
2010
  }
2124
2011
 
2125
- // Fire-and-forget. If the page was just created/rebuilt, defer the BMP send 1s to let the
2126
- // glasses process the page (matches iOS's await). Reuse path sends immediately.
2127
- if (freshPage || needsRebuild) {
2128
- mainHandler.postDelayed(
2129
- { sendImageData(container.id, container.name, bmpData) },
2130
- 1000
2131
- )
2012
+ // Reuse an existing container if the rect matches exactly; otherwise add a new one.
2013
+ val container: ImgContainer
2014
+ val existingIndex = imageContainers.indexOfFirst { it.matches(rx, ry, rw, rh) }
2015
+ if (existingIndex >= 0) {
2016
+ imageContainers[existingIndex].bmpData = bmpData
2017
+ container = imageContainers[existingIndex]
2018
+ Bridge.log("G2: displayBitmap() - reusing container ${container.id} for rect $rx,$ry ${rw}x$rh")
2019
+ displayScope.launch {
2020
+ displayMutex.withLock {
2021
+ if (!pageCreated) {
2022
+ rebuildPage()
2023
+ } else {
2024
+ sendImageData(container.id, container.name, container.bmpData)
2025
+ }
2026
+ }
2027
+ }
2028
+ return true
2132
2029
  } else {
2133
- sendImageData(container.id, container.name, bmpData)
2030
+ container = addImageContainer(rx, ry, rw, rh, bmpData)
2031
+ Bridge.log("G2: displayBitmap() - added container ${container.id} for rect $rx,$ry ${rw}x$rh, rebuilding page")
2032
+ displayScope.launch {
2033
+ displayMutex.withLock {
2034
+ rebuildPage()
2035
+ }
2036
+ }
2134
2037
  }
2135
2038
 
2136
2039
  return true
@@ -2140,7 +2043,13 @@ class G2 : SGCManager() {
2140
2043
  * Add a new image container for the rect, evicting the oldest when the list is full (max 4).
2141
2044
  * Returns the newly tracked container (with an assigned ID from the pool).
2142
2045
  */
2143
- private fun addImageContainer(x: Int, y: Int, width: Int, height: Int): ImgContainer {
2046
+ private fun addImageContainer(
2047
+ x: Int,
2048
+ y: Int,
2049
+ width: Int,
2050
+ height: Int,
2051
+ bmpData: ByteArray
2052
+ ): ImgContainer {
2144
2053
  // Evict the oldest container when at capacity, freeing its ID for reuse.
2145
2054
  if (imageContainers.size >= imageContainerIDPool.size) {
2146
2055
  val evicted = imageContainers.removeAt(0)
@@ -2149,21 +2058,106 @@ class G2 : SGCManager() {
2149
2058
  // Pick the lowest free ID from the pool.
2150
2059
  val usedIDs = imageContainers.map { it.id }.toSet()
2151
2060
  val id = imageContainerIDPool.firstOrNull { it !in usedIDs } ?: imageContainerIDPool[0]
2152
- val container = ImgContainer(id = id, x = x, y = y, width = width, height = height)
2061
+ val container =
2062
+ ImgContainer(id = id, x = x, y = y, width = width, height = height, bmpData = bmpData)
2153
2063
  imageContainers.add(container)
2154
2064
  return container
2155
2065
  }
2156
2066
 
2157
- /** Rebuild the page from the current text + image container list. */
2158
- private fun rebuildPage() {
2159
- createPageWithText(currentTextContent)
2067
+ private fun addTextContainer(
2068
+ x: Int,
2069
+ y: Int,
2070
+ width: Int,
2071
+ height: Int,
2072
+ content: String,
2073
+ borderWidth: Int,
2074
+ borderColor: Int,
2075
+ borderRadius: Int,
2076
+ paddingLength: Int
2077
+ ): TextContainer {
2078
+ // Evict the oldest container when at capacity, freeing its ID for reuse.
2079
+ if (textContainers.size >= textContainerIDPool.size) {
2080
+ val evicted = textContainers.removeAt(0)
2081
+ Bridge.log("G2: evicting oldest text container ${evicted.id}")
2082
+ }
2083
+ // Pick the lowest free ID from the pool.
2084
+ val usedIDs = textContainers.map { it.id }.toSet()
2085
+ val id = textContainerIDPool.firstOrNull { it !in usedIDs } ?: textContainerIDPool[0]
2086
+ val container =
2087
+ TextContainer(
2088
+ id = id,
2089
+ x = x,
2090
+ y = y,
2091
+ width = width,
2092
+ height = height,
2093
+ content = content,
2094
+ borderWidth = borderWidth,
2095
+ borderColor = borderColor,
2096
+ borderRadius = borderRadius,
2097
+ paddingLength = paddingLength
2098
+ )
2099
+ textContainers.add(container)
2100
+ return container
2101
+ }
2102
+
2103
+ /**
2104
+ * Shutdown and rebuild everything, re-sending all data to the glasses.
2105
+ *
2106
+ * Suspends until the rebuild (shutdown → create → image/text re-send) has been issued, so
2107
+ * callers no longer race a detached coroutine. Always invoke from within [displayMutex].
2108
+ */
2109
+ private suspend fun rebuildPage() {
2110
+ val msg = EvenHubProto.shutdownMessage()
2111
+ sendEvenHubCommand(msg)
2112
+ pageCreated = false
2113
+ rebuildState()
2114
+ }
2115
+
2116
+ /**
2117
+ * Re-creates the containers and sends all images and text again to the glasses.
2118
+ *
2119
+ * Runs inline (suspending) rather than launching a detached coroutine, so the page is fully
2120
+ * rebuilt before the caller continues. Always invoke from within [displayMutex].
2121
+ */
2122
+ private suspend fun rebuildState() {
2123
+ Bridge.log("G2: rebuildState()")
2124
+ // recreate the containers:
2125
+ createPageWithContainers()
2126
+
2127
+ delay(300) // 300ms to settle
2128
+
2129
+ // go through each image container and send the data:
2130
+ for (container in imageContainers) {
2131
+ sendImageData(container.id, container.name, container.bmpData)
2132
+ delay(300) // 300ms between containers
2133
+ }
2134
+
2135
+ // go through each text container and re-send its content, otherwise text stays blank after a
2136
+ // disabled because text containers are initialized with their content:
2137
+ // for (container in textContainers) {
2138
+ // val textMsg =
2139
+ // EvenHubProto.updateTextMessage(
2140
+ // containerID = container.id,
2141
+ // contentOffset = 0,
2142
+ // contentLength = container.content.toByteArray(Charsets.UTF_8).size,
2143
+ // content = container.content
2144
+ // )
2145
+ // sendEvenHubCommand(textMsg)
2146
+ // delay(100)
2147
+ // }
2160
2148
  }
2161
2149
 
2162
- private fun sendImageData(
2150
+ /**
2151
+ * Send a bitmap to an image container as fragmented updateImageRawData packets.
2152
+ *
2153
+ * Suspends until every fragment has been sent, mirroring iOS `sendImageData` (which awaits
2154
+ * 200ms after every fragment, including the last). Callers therefore get the same serialized
2155
+ * timing window: the 300ms settle in [rebuildState] only runs once all fragments are out.
2156
+ */
2157
+ private suspend fun sendImageData(
2163
2158
  containerID: Int,
2164
2159
  containerName: String,
2165
- bmpData: ByteArray,
2166
- onComplete: (() -> Unit)? = null
2160
+ bmpData: ByteArray
2167
2161
  ) {
2168
2162
  val fragmentSize = 4096
2169
2163
  imageSessionCounter++
@@ -2172,15 +2166,9 @@ class G2 : SGCManager() {
2172
2166
  var fragmentIndex = 0
2173
2167
  var offset = 0
2174
2168
 
2175
- fun sendNextFragment() {
2176
- if (offset >= bmpData.size) {
2177
- Bridge.log(
2178
- "G2: sendImageData($containerName) - $fragmentIndex fragments, ${bmpData.size} bytes"
2179
- )
2180
- onComplete?.invoke()
2181
- return
2182
- }
2169
+ Bridge.log("G2: sendImageData($containerName) - $fragmentIndex fragments, ${bmpData.size} bytes")
2183
2170
 
2171
+ while (offset < bmpData.size) {
2184
2172
  val end = minOf(offset + fragmentSize, bmpData.size)
2185
2173
  val fragment = bmpData.copyOfRange(offset, end)
2186
2174
 
@@ -2200,20 +2188,8 @@ class G2 : SGCManager() {
2200
2188
 
2201
2189
  fragmentIndex++
2202
2190
  offset = end
2203
-
2204
- // 200ms between fragments — and also before onComplete (matches iOS, which awaits
2205
- // 200ms after every fragment including the last).
2206
- if (offset < bmpData.size) {
2207
- mainHandler.postDelayed({ sendNextFragment() }, 200)
2208
- } else {
2209
- Bridge.log(
2210
- "G2: sendImageData($containerName) - $fragmentIndex fragments, ${bmpData.size} bytes"
2211
- )
2212
- mainHandler.postDelayed({ onComplete?.invoke() }, 200)
2213
- }
2191
+ delay(200) // 200ms between fragments (and after the last, matching iOS)
2214
2192
  }
2215
-
2216
- sendNextFragment()
2217
2193
  }
2218
2194
 
2219
2195
  /// Bring the Even Realities dashboard (the OS-level home/idle screen) to
@@ -2225,8 +2201,6 @@ class G2 : SGCManager() {
2225
2201
  val msg = EvenHubProto.shutdownMessage()
2226
2202
  sendEvenHubCommand(msg)
2227
2203
  pageCreated = false
2228
- pageHasTextContainer = false
2229
- currentTextContent = ""
2230
2204
  currentBitmapBase64 = ""
2231
2205
  mainHandler.postDelayed({
2232
2206
  // activate the dashboard by setting depth to the current setting:
@@ -2332,7 +2306,7 @@ class G2 : SGCManager() {
2332
2306
  * The SKILL step alone is ignored by firmware; the preceding ENTER+ASK
2333
2307
  * supply the session context that lets the glasses act on the SKILL.
2334
2308
  */
2335
- override fun showNotificationsPanel() {
2309
+ override suspend fun showNotificationsPanel() {
2336
2310
  Bridge.log("G2: showNotificationsPanel()")
2337
2311
  val enterPayload = EvenAIProto.aiCtrl(
2338
2312
  magicRandom = sendManager.nextMagicRandom(),
@@ -2340,26 +2314,24 @@ class G2 : SGCManager() {
2340
2314
  )
2341
2315
  sendEvenAICommand(enterPayload)
2342
2316
 
2343
- mainHandler.postDelayed({
2344
- val askPayload = EvenAIProto.aiAsk(
2345
- magicRandom = sendManager.nextMagicRandom(),
2346
- text = " ",
2347
- streamEnable = 0
2348
- )
2349
- sendEvenAICommand(askPayload)
2317
+ delay(400)
2318
+ val askPayload = EvenAIProto.aiAsk(
2319
+ magicRandom = sendManager.nextMagicRandom(),
2320
+ text = " ",
2321
+ streamEnable = 0
2322
+ )
2323
+ sendEvenAICommand(askPayload)
2350
2324
 
2351
- mainHandler.postDelayed({
2352
- val skillPayload = EvenAIProto.triggerSkill(
2353
- magicRandom = sendManager.nextMagicRandom(),
2354
- skillId = 3, // NOTIFICATION
2355
- skillParam = 1, // show
2356
- text = " ",
2357
- streamEnable = 1,
2358
- fTextEnd = 1
2359
- )
2360
- sendEvenAICommand(skillPayload)
2361
- }, 400)
2362
- }, 400)
2325
+ delay(400)
2326
+ val skillPayload = EvenAIProto.triggerSkill(
2327
+ magicRandom = sendManager.nextMagicRandom(),
2328
+ skillId = 3, // NOTIFICATION
2329
+ skillParam = 1, // show
2330
+ text = " ",
2331
+ streamEnable = 1,
2332
+ fTextEnd = 1
2333
+ )
2334
+ sendEvenAICommand(skillPayload)
2363
2335
  }
2364
2336
 
2365
2337
  override fun setBrightness(level: Int, autoMode: Boolean) {
@@ -2375,22 +2347,45 @@ class G2 : SGCManager() {
2375
2347
 
2376
2348
  // ---------- Private Display Helpers ----------
2377
2349
 
2378
- private fun createPageWithText(text: String) {
2379
- val tc =
2380
- EvenHubProto.textContainerProperty(
2381
- x = 0,
2382
- y = 0,
2383
- width = 576,
2384
- height = 288,
2385
- borderWidth = 0,
2386
- borderColor = 0,
2387
- borderRadius = 0,
2388
- paddingLength = 4,
2389
- containerID = textContainerID,
2390
- containerName = "text-main",
2391
- isEventCapture = true,
2392
- content = text
2350
+ private fun createPageWithContainers() {
2351
+ // build the page's text containers from the live tracked list.
2352
+ val textContainerProps: List<ByteArray> = ArrayList<ByteArray>(textContainers.size).apply {
2353
+ for (i in textContainers.indices) {
2354
+ val c = textContainers[i]
2355
+ add(
2356
+ EvenHubProto.textContainerProperty(
2357
+ x = c.x,
2358
+ y = c.y,
2359
+ width = c.width,
2360
+ height = c.height,
2361
+ borderWidth = c.borderWidth,
2362
+ borderColor = c.borderColor,
2363
+ borderRadius = c.borderRadius,
2364
+ paddingLength = c.paddingLength,
2365
+ containerID = c.id,
2366
+ containerName = c.name,
2367
+ isEventCapture = i == 0,// the first container is the event capture container
2368
+ content = c.content
2369
+ )
2393
2370
  )
2371
+ }
2372
+ }
2373
+
2374
+
2375
+ // iterate all image containers, remove any entries with duplicate ids or empty data,
2376
+ // and ensure the ids are still in the imageContainerIDPool:
2377
+ val seenIDs = mutableSetOf<Int>()
2378
+ imageContainers.retainAll { c ->
2379
+ if (c.bmpData.isEmpty()) {
2380
+ Bridge.log("G2: removing empty image container ${c.id}")
2381
+ return@retainAll false
2382
+ }
2383
+ if (!seenIDs.add(c.id)) {
2384
+ Bridge.log("G2: removing duplicate image container ${c.id}")
2385
+ return@retainAll false
2386
+ }
2387
+ imageContainerIDPool.contains(c.id)
2388
+ }
2394
2389
 
2395
2390
  // Build the page's image containers from the live tracked list.
2396
2391
  val imageContainerProps: List<ByteArray> =
@@ -2406,21 +2401,20 @@ class G2 : SGCManager() {
2406
2401
  }
2407
2402
 
2408
2403
  val msg: ByteArray
2409
- if (!startupPageCreated) {
2410
- Bridge.log("G2: createPageWithText - using createPageMessage (first time)")
2404
+ if (!pageCreated) {
2405
+ Bridge.log("G2: using createPageMessage (first time)")
2411
2406
  msg =
2412
2407
  EvenHubProto.createPageMessage(
2413
- textContainers = listOf(tc),
2408
+ textContainers = textContainerProps,
2414
2409
  imageContainers = imageContainerProps,
2415
2410
  magicRandom = sendManager.nextMagicRandom(),
2416
2411
  appId = activeMenuAppId
2417
2412
  )
2418
- startupPageCreated = true
2419
2413
  } else {
2420
- Bridge.log("G2: createPageWithText - using rebuildPageMessage")
2414
+ Bridge.log("G2: using rebuildPageMessage")
2421
2415
  msg =
2422
2416
  EvenHubProto.rebuildPageMessage(
2423
- textContainers = listOf(tc),
2417
+ textContainers = textContainerProps,
2424
2418
  imageContainers = imageContainerProps,
2425
2419
  magicRandom = sendManager.nextMagicRandom(),
2426
2420
  appId = activeMenuAppId
@@ -2428,22 +2422,6 @@ class G2 : SGCManager() {
2428
2422
  }
2429
2423
  sendEvenHubCommand(msg)
2430
2424
  pageCreated = true
2431
- pageHasTextContainer = true
2432
- currentTextContent = text
2433
- currentBitmapBase64 = ""
2434
- }
2435
-
2436
- private fun updateText(text: String) {
2437
- val msg =
2438
- EvenHubProto.updateTextMessage(
2439
- containerID = textContainerID,
2440
- contentOffset = 0,
2441
- contentLength = text.toByteArray(Charsets.UTF_8).size,
2442
- content = text
2443
- )
2444
- queueEvenHubCommand(msg)
2445
- currentTextContent = text
2446
- currentBitmapBase64 = ""
2447
2425
  }
2448
2426
 
2449
2427
  @Synchronized
@@ -2593,9 +2571,9 @@ class G2 : SGCManager() {
2593
2571
  bmp.write(rowBuf)
2594
2572
  }
2595
2573
 
2596
- Bridge.log(
2597
- "G2: build4BitBmp - ${bmp.size()} bytes (header=$headerSize, pixels=$pixelDataSize, rows=${paddedRowSize}x$height)"
2598
- )
2574
+ // Bridge.log(
2575
+ // "G2: build4BitBmp - ${bmp.size()} bytes (header=$headerSize, pixels=$pixelDataSize, rows=${paddedRowSize}x$height)"
2576
+ // )
2599
2577
  return bmp.toByteArray()
2600
2578
  }
2601
2579
 
@@ -2613,6 +2591,13 @@ class G2 : SGCManager() {
2613
2591
 
2614
2592
  // ---------- SGCManager: Audio Control ----------
2615
2593
 
2594
+ private fun restartMicIfAlreadyEnabled() {
2595
+ val currentEnabled = DeviceStore.get("glasses", "micEnabled") as? Boolean ?: false
2596
+ if (currentEnabled) {
2597
+ restartMic()
2598
+ }
2599
+ }
2600
+
2616
2601
  fun restartMic() {
2617
2602
  // if already enabled, set to disabled, then send enabled after 500ms:
2618
2603
  DeviceStore.apply("glasses", "micEnabled", true)
@@ -2620,11 +2605,11 @@ class G2 : SGCManager() {
2620
2605
  sendEvenHubCommand(msg)
2621
2606
  mainHandler.postDelayed({
2622
2607
  val useNativeDashboard = DeviceStore.get("bluetooth", "use_native_dashboard") as? Boolean ?: false
2623
- Bridge.log("G2: setMicEnabled - useNativeDashboard=$useNativeDashboard, dashboardShowing=$dashboardShowing")
2608
+ // Bridge.log("G2: setMicEnabled - useNativeDashboard=$useNativeDashboard, dashboardShowing=$dashboardShowing")
2624
2609
  if (useNativeDashboard && dashboardShowing > 0) {
2625
2610
  return@postDelayed
2626
2611
  }
2627
- if (!pageCreated || !pageHasTextContainer) {
2612
+ if (!pageCreated) {
2628
2613
  DeviceManager.getInstance().sendCurrentState() // should re-create the page if needed
2629
2614
  }
2630
2615
  val msg = EvenHubProto.audioControlMessage(true)
@@ -2762,8 +2747,8 @@ class G2 : SGCManager() {
2762
2747
  rightAuthenticated = false
2763
2748
  startupPageCreated = false
2764
2749
  pageCreated = false
2765
- pageHasTextContainer = false
2766
2750
  imageContainers.clear()
2751
+ textContainers.clear()
2767
2752
  dashboardShowing = 0
2768
2753
  heartbeatCounter = 0
2769
2754
  currentBitmapBase64 = ""
@@ -2807,10 +2792,78 @@ class G2 : SGCManager() {
2807
2792
  }
2808
2793
 
2809
2794
  override fun dbg1() {
2810
- showNotificationsPanel()
2795
+ // showNotificationsPanel()
2811
2796
  }
2797
+
2798
+ private var compassRunning = false
2799
+
2812
2800
  override fun dbg2() {
2813
- disconnectController()
2801
+ // compassRunning = !compassRunning
2802
+ // Bridge.log("G2: dbg2() — ${if (compassRunning) "start" else "stop"} compass")
2803
+ // if (compassRunning) {
2804
+ // startCompass()
2805
+ // } else {
2806
+ // stopCompass()
2807
+ // }
2808
+ }
2809
+
2810
+ /**
2811
+ * Start a navigation session so the glasses stream compass heading via
2812
+ * OS_NOTIFY_COMPASS_CHANGED — surfaced as `CompassHeadingEvent { heading: 0…359 }` in
2813
+ * handleNavigationResponse.
2814
+ *
2815
+ * If the magnetometer needs calibration, the glasses emit OS_NOTIFY_COMPASS_CALIBRATE_STRAT
2816
+ * (→ `CompassCalibrationEvent {status:"start"}`); the wearer should look around until
2817
+ * `…{status:"complete"}`.
2818
+ */
2819
+ fun startCompass() {
2820
+ val w = ProtobufWriter()
2821
+ w.writeInt32Field(1, NavigationCmd.APP_REQUEST_START_UP.value) // cmd
2822
+ w.writeInt32Field(2, sendManager.nextMagicRandom()) // magicRandom
2823
+ sendNavigationCommand(w.toByteArray())
2824
+ }
2825
+
2826
+ /** Stop the navigation/compass session (ends heading streaming). */
2827
+ fun stopCompass() {
2828
+ val w = ProtobufWriter()
2829
+ w.writeInt32Field(1, NavigationCmd.APP_REQUEST_EXIT.value)
2830
+ w.writeInt32Field(2, sendManager.nextMagicRandom())
2831
+ sendNavigationCommand(w.toByteArray())
2832
+ }
2833
+
2834
+ override suspend fun setImuEnabled(enabled: Boolean) {
2835
+ setImuEnabled(enabled, reportFrq = EvenHubProto.IMU_PACE_P100)
2836
+ }
2837
+
2838
+ /**
2839
+ * Enable or disable IMU motion reporting on the glasses.
2840
+ *
2841
+ * When enabled, the glasses continuously push `IMU_Report_Data { x, y, z }` (32-bit floats,
2842
+ * gravity-normalized) via the EvenHub notify path; these surface in `handleTouchEvent` as a
2843
+ * Sys_ItemEvent with `eventType == IMU_DATA_REPORT (8)` and are emitted through
2844
+ * `Bridge.sendAccelEvent` (a single accelerometer reading; a richer combined IMU event
2845
+ * covering gyro + magnetometer is future work).
2846
+ */
2847
+ suspend fun setImuEnabled(enabled: Boolean, reportFrq: Int) {
2848
+ Bridge.log("G2: setImuEnabled($enabled, frq=$reportFrq)")
2849
+
2850
+ displayMutex.withLock {
2851
+ // IMU requires an active EvenHub page (same prerequisite as the mic). Await the
2852
+ // rebuild so the control packet is sent only after the page actually exists — page
2853
+ // creation is async with variable delays, so a fixed wait could send too early and
2854
+ // reporting would never start.
2855
+ if (enabled && !pageCreated) {
2856
+ rebuildState()
2857
+ }
2858
+
2859
+ val msg =
2860
+ EvenHubProto.imuControlMessage(
2861
+ enable = enabled,
2862
+ reportFrq = reportFrq,
2863
+ magicRandom = sendManager.nextMagicRandom()
2864
+ )
2865
+ sendEvenHubCommand(msg)
2866
+ }
2814
2867
  }
2815
2868
 
2816
2869
  fun reconnectController() {
@@ -2899,7 +2952,12 @@ class G2 : SGCManager() {
2899
2952
  }
2900
2953
 
2901
2954
  override fun sendShutdown() {
2902
- clearDisplay()
2955
+ // Send the EvenHub shutdown synchronously before tearing down BLE. clearDisplay() is now
2956
+ // fire-and-forget (it serializes on displayScope), so calling it here would let disconnect()
2957
+ // close the GATT before the deferred shutdown packet was ever written.
2958
+ val msg = EvenHubProto.shutdownMessage()
2959
+ sendEvenHubCommand(msg)
2960
+ pageCreated = false
2903
2961
  disconnect()
2904
2962
  }
2905
2963
 
@@ -3248,7 +3306,6 @@ class G2 : SGCManager() {
3248
3306
 
3249
3307
  startupPageCreated = false
3250
3308
  pageCreated = false
3251
- pageHasTextContainer = false
3252
3309
  dashboardShowing = 0
3253
3310
  DeviceStore.apply("glasses", "connected", false)
3254
3311
  DeviceStore.apply("glasses", "fullyBooted", false)
@@ -3335,7 +3392,7 @@ class G2 : SGCManager() {
3335
3392
  stopScan()
3336
3393
  authStarted = true
3337
3394
  Bridge.log("G2: Both sides initialized, starting auth sequence")
3338
- runAuthSequence()
3395
+ displayScope.launch { runAuthSequence() }
3339
3396
  }
3340
3397
  }
3341
3398
  }
@@ -3415,6 +3472,8 @@ class G2 : SGCManager() {
3415
3472
  ServiceID.MENU.value -> handleMenuResponse(payload)
3416
3473
  ServiceID.DASHBOARD.value -> handleDashboardResponse(payload)
3417
3474
  ServiceID.GESTURE_CTRL.value -> handleGestureCtrl(payload)
3475
+ ServiceID.NAVIGATION.value -> handleNavigationResponse(payload)
3476
+ ServiceID.EVEN_AI.value -> handleEvenAIResponse(payload)
3418
3477
  ServiceID.EVEN_HUB_CTRL.value -> handleEvenHubCtrlResponse(payload)
3419
3478
  else -> {
3420
3479
  Bridge.log(
@@ -3426,6 +3485,69 @@ class G2 : SGCManager() {
3426
3485
  }
3427
3486
  }
3428
3487
 
3488
+ /**
3489
+ * EvenAI service (0x07). Logs the decoded EvenAIDataPackage so we can read the CONFIG
3490
+ * (Hey Even) echo: commandId=10 (CONFIG), config sub-message in field 13.
3491
+ */
3492
+ private fun handleEvenAIResponse(payload: ByteArray) {
3493
+ val reader = ProtobufReader(payload)
3494
+ val fields = reader.parseFields()
3495
+ val cmd = fields[1] as? Int ?: -1
3496
+ val configData = fields[13] as? ByteArray
3497
+ if (cmd == 10 && configData != null) {
3498
+ val cReader = ProtobufReader(configData)
3499
+ val cFields = cReader.parseFields()
3500
+ val voiceSwitch = cFields[1] as? Int ?: 0 // omitted = 0 = OFF
3501
+ Bridge.log(
3502
+ "G2: EvenAI CONFIG echo — voiceSwitch=$voiceSwitch (${if (voiceSwitch == 1) "ON" else "OFF"}) config=$cFields"
3503
+ )
3504
+ } else {
3505
+ Bridge.log(
3506
+ "G2: EvenAI cmd=$cmd fields=${fields.keys.sorted()} raw=${payload.joinToString("") { String.format("%02X", it) }}"
3507
+ )
3508
+ }
3509
+ }
3510
+
3511
+ /**
3512
+ * Navigation service (0x08).
3513
+ *
3514
+ * OS_NOTIFY_COMPASS_CHANGED (15) carries the magnetometer heading in compass_info_msg
3515
+ * (field 10) → field 1, as whole degrees 0…359. (The proto names that field `compassIndex`,
3516
+ * but on the notify path it's the live heading — verified on-device: values sweep 0–359 as
3517
+ * the wearer turns.)
3518
+ */
3519
+ private fun handleNavigationResponse(payload: ByteArray) {
3520
+ val reader = ProtobufReader(payload)
3521
+ val fields = reader.parseFields()
3522
+ val cmd = fields[1] as? Int ?: return
3523
+
3524
+ when (cmd) {
3525
+ NavigationCmd.OS_NOTIFY_COMPASS_CHANGED.value -> {
3526
+ val compassData = fields[10] as? ByteArray ?: return
3527
+ val cReader = ProtobufReader(compassData)
3528
+ val cFields = cReader.parseFields()
3529
+ val heading = cFields[1] as? Int ?: return
3530
+ // Heading in degrees, 0…359.
3531
+ Bridge.log("G2: compass heading=$heading°")
3532
+ Bridge.sendTypedMessage(
3533
+ "CompassHeadingEvent",
3534
+ mapOf(
3535
+ "heading" to heading,
3536
+ "timestamp" to System.currentTimeMillis()
3537
+ )
3538
+ )
3539
+ }
3540
+ NavigationCmd.OS_NOTIFY_COMPASS_CALIBRATE_START.value -> {
3541
+ Bridge.log("G2: compass calibration started — wearer should look around")
3542
+ Bridge.sendTypedMessage("CompassCalibrationEvent", mapOf("status" to "start"))
3543
+ }
3544
+ NavigationCmd.OS_NOTIFY_COMPASS_CALIBRATE_COMPLETE.value -> {
3545
+ Bridge.log("G2: compass calibration complete")
3546
+ Bridge.sendTypedMessage("CompassCalibrationEvent", mapOf("status" to "complete"))
3547
+ }
3548
+ }
3549
+ }
3550
+
3429
3551
  private fun handleEvenHubResponse(payload: ByteArray) {
3430
3552
  val reader = ProtobufReader(payload)
3431
3553
  val fields = reader.parseFields()
@@ -3476,7 +3598,22 @@ class G2 : SGCManager() {
3476
3598
  Bridge.log("G2: Menu selection ignored — placeholder or unknown appId=$appId")
3477
3599
  }
3478
3600
  } else {
3479
- // Parse error codes from responses
3601
+ // Dedup only the non-critical logging path (img-success/error chatter), which L and R
3602
+ // both deliver. Page-state resets above are intentionally outside this window.
3603
+ val timestamp = System.currentTimeMillis()
3604
+ val lastResponse = lastEvenHubResponseTimestamp
3605
+ if (lastResponse != null && timestamp - lastResponse < 100) {
3606
+ return
3607
+ }
3608
+ lastEvenHubResponseTimestamp = timestamp
3609
+
3610
+ // If glasses sent a shutdown (cmd=9/10), our page is gone — reset state.
3611
+ if (cmdValue == 9 || cmdValue == 10) {
3612
+ Bridge.log("G2: ERROR: Glasses shutdown our EvenHub page — resetting page state")
3613
+ pageCreated = false
3614
+ }
3615
+
3616
+ // Scan response fields for a shutdown/error code regardless of the debounce window.
3480
3617
  // field 4 = StartupResCmd, field 6 = ImgResCmd, field 8 = RebuildResCmd, field 10 =
3481
3618
  // TextResCmd
3482
3619
  for (resField in listOf(4, 6, 8, 10)) {
@@ -3490,12 +3627,15 @@ class G2 : SGCManager() {
3490
3627
  Bridge.log(
3491
3628
  "G2: WARN: Glasses shutdown our EvenHub page — resetting page state"
3492
3629
  )
3493
- startupPageCreated = false
3494
3630
  pageCreated = false
3495
- pageHasTextContainer = false
3496
- currentTextContent = ""
3497
3631
  }
3498
3632
  }
3633
+ }
3634
+
3635
+ for (resField in listOf(4, 6, 8, 10)) {
3636
+ val resData = fields[resField] as? ByteArray ?: continue
3637
+ val resReader = ProtobufReader(resData)
3638
+ val resFields = resReader.parseFields()
3499
3639
  (resFields[8] as? Int)?.let { errorCode ->
3500
3640
  // ImgResCmd has ErrorCode in field 8
3501
3641
  if (errorCode == 4) {
@@ -3505,15 +3645,6 @@ class G2 : SGCManager() {
3505
3645
  }
3506
3646
  }
3507
3647
  }
3508
-
3509
- // If glasses sent a shutdown (cmd=9/10), our page is gone — reset state
3510
- if (cmdValue == 9 || cmdValue == 10) {
3511
- Bridge.log("G2: ERROR: Glasses shutdown our EvenHub page — resetting page state")
3512
- startupPageCreated = false
3513
- pageCreated = false
3514
- pageHasTextContainer = false
3515
- currentTextContent = ""
3516
- }
3517
3648
  }
3518
3649
  }
3519
3650
 
@@ -3541,6 +3672,39 @@ class G2 : SGCManager() {
3541
3672
  }
3542
3673
  }
3543
3674
 
3675
+ /**
3676
+ * Parse an IMU_Report_Data sub-message: fields 1/2/3 = x/y/z as 32-bit floats (wire type 5).
3677
+ * `ProtobufReader.parseFields()` skips wire-type-5 fields, so this walks the bytes manually.
3678
+ */
3679
+ private fun parseImuReportData(data: ByteArray): Triple<Float, Float, Float>? {
3680
+ var x: Float? = null
3681
+ var y: Float? = null
3682
+ var z: Float? = null
3683
+ var i = 0
3684
+ while (i < data.size) {
3685
+ val tag = data[i].toInt() and 0xFF
3686
+ i += 1
3687
+ val fieldNum = tag shr 3
3688
+ val wireType = tag and 0x07
3689
+ if (wireType != 5 || data.size - i < 4) break
3690
+ var bits = 0
3691
+ for (b in 0 until 4) {
3692
+ bits = bits or ((data[i + b].toInt() and 0xFF) shl (8 * b)) // little-endian
3693
+ }
3694
+ i += 4
3695
+ val value = Float.fromBits(bits)
3696
+ when (fieldNum) {
3697
+ 1 -> x = value
3698
+ 2 -> y = value
3699
+ 3 -> z = value
3700
+ }
3701
+ }
3702
+ val fx = x ?: return null
3703
+ val fy = y ?: return null
3704
+ val fz = z ?: return null
3705
+ return Triple(fx, fy, fz)
3706
+ }
3707
+
3544
3708
  private fun handleTouchEvent(devEventData: ByteArray) {
3545
3709
  // Parse SendDeviceEvent: field 1=ListEvent, field 2=TextEvent, field 3=SysEvent
3546
3710
  val reader = ProtobufReader(devEventData)
@@ -3556,6 +3720,19 @@ class G2 : SGCManager() {
3556
3720
  val sysReader = ProtobufReader(sysData)
3557
3721
  val sysFields = sysReader.parseFields()
3558
3722
 
3723
+ // IMU data report: eventType == IMU_DATA_REPORT (8), imuData in field 3
3724
+ // (IMU_Report_Data { x, y, z } as 32-bit floats). Handle and return before the
3725
+ // gesture-mapping path.
3726
+ val imuData = sysFields[3] as? ByteArray
3727
+ if ((sysFields[1] as? Int) == OsEventType.IMU_DATA_REPORT.value && imuData != null) {
3728
+ val imu = parseImuReportData(imuData)
3729
+ if (imu != null) {
3730
+ Bridge.log("G2: IMU data report: ${imu.first}, ${imu.second}, ${imu.third}")
3731
+ Bridge.sendAccelEvent(imu.first, imu.second, imu.third, timestamp)
3732
+ return
3733
+ }
3734
+ }
3735
+
3559
3736
  val normalType = sysFields[1] as? Int
3560
3737
  val eventType: OsEventType? =
3561
3738
  if (normalType != null) OsEventType.fromInt(normalType) else OsEventType.CLICK
@@ -3596,11 +3773,7 @@ class G2 : SGCManager() {
3596
3773
  // System exit: glasses killed our EvenHub page (user opened menu or another app)
3597
3774
  // Reset page state and re-create the page to reclaim EvenHub focus
3598
3775
  if (eventType == OsEventType.SYSTEM_EXIT || eventType == OsEventType.ABNORMAL_EXIT) {
3599
- startupPageCreated = false
3600
3776
  pageCreated = false
3601
- pageHasTextContainer = false
3602
- currentTextContent = ""
3603
- currentBitmapBase64 = ""
3604
3777
  // Firmware kills the mic on system exit; re-arm it if it should be on
3605
3778
  DeviceStore.apply("glasses", "micEnabled", false)
3606
3779
  DeviceManager.getInstance().updateMicState()
@@ -3641,7 +3814,8 @@ class G2 : SGCManager() {
3641
3814
  OsEventType.FOREGROUND_ENTER -> "foreground_enter"
3642
3815
  OsEventType.FOREGROUND_EXIT -> "foreground_exit"
3643
3816
  OsEventType.SYSTEM_EXIT -> "system_exit"
3644
- OsEventType.ABNORMAL_EXIT -> null
3817
+ OsEventType.IMU_DATA_REPORT -> null
3818
+ OsEventType.ABNORMAL_EXIT -> null // don't report abnormal exits as gestures
3645
3819
  }
3646
3820
  }
3647
3821
 
@@ -3653,9 +3827,9 @@ class G2 : SGCManager() {
3653
3827
  // Ignore heartbeat acks
3654
3828
  if (cmdValue == DevCfgCommandId.BASE_CONN_HEART_BEAT.value) return
3655
3829
 
3656
- Bridge.log(
3657
- "G2: DevSettings response: ${payload.take(32).joinToString(":") { String.format("%02X", it) }}"
3658
- )
3830
+ // Bridge.log(
3831
+ // "G2: DevSettings response: ${payload.take(32).joinToString(":") { String.format("%02X", it) }}"
3832
+ // )
3659
3833
 
3660
3834
  if (cmdValue == DevCfgCommandId.AUTHENTICATION.value) {
3661
3835
  // DevCfgDataPackage: field 2 = magicRandom, field 3 = AuthMgr { field 1 = secAuth }
@@ -3697,7 +3871,7 @@ class G2 : SGCManager() {
3697
3871
  }
3698
3872
 
3699
3873
  val connStatus = ringFields[4] as? Int ?: -1
3700
- Bridge.log("G2: Ring connection status: connStatus?=$connStatus")
3874
+ // Bridge.log("G2: Ring connection status: connStatus?=$connStatus")
3701
3875
 
3702
3876
  if (connStatus == 22) {
3703
3877
  Bridge.log("G2: Ring disconnected")
@@ -3765,7 +3939,7 @@ class G2 : SGCManager() {
3765
3939
  val timestamp = System.currentTimeMillis()
3766
3940
  val last = lastGestureCtrlTimestamp
3767
3941
  if (last != null && timestamp - last < 500) {
3768
- Bridge.log("G2: gesture_ctrl dedup")
3942
+ // Bridge.log("G2: gesture_ctrl dedup")
3769
3943
  return
3770
3944
  }
3771
3945
  lastGestureCtrlTimestamp = timestamp
@@ -3775,24 +3949,36 @@ class G2 : SGCManager() {
3775
3949
  Bridge.log("G2: dashboard closed / shutdown - dashboardShowing=$dashboardShowing")
3776
3950
  val useNativeDashboard = DeviceStore.get("bluetooth", "use_native_dashboard") as? Boolean ?: false
3777
3951
  if (!useNativeDashboard) {
3778
- // make sure the container exists:
3779
- DeviceManager.getInstance().sendCurrentState()
3780
- // re-send mic on / if it's enabled:
3781
- val micEnabled = DeviceStore.get("glasses", "micEnabled") as? Boolean ?: false
3782
- if (micEnabled) {
3783
- restartMic()
3952
+ dashboardShowing = 0
3953
+ // Rebuild the page from cached containers, then reconcile against
3954
+ // DeviceManager's authoritative current view so the glasses match the phone
3955
+ // (not just the last-cached G2 containers) after returning from the dashboard.
3956
+ displayScope.launch {
3957
+ displayMutex.withLock { rebuildState() }
3958
+ DeviceManager.getInstance().sendCurrentState()
3959
+ // set the mic back on if it should be on — only after the rebuild
3960
+ // completes, matching iOS (which awaits rebuildState before restartMic).
3961
+ val micEnabled = DeviceStore.get("glasses", "micEnabled") as? Boolean ?: false
3962
+ if (micEnabled) {
3963
+ restartMic()
3964
+ }
3784
3965
  }
3966
+ return
3785
3967
  } else {
3786
3968
  // if we aren't trying to show the dashboard
3787
3969
  // then we need to turn the mic back on and display the mentra main page:
3788
3970
  if (dashboardShowing <= 1) {
3789
3971
  dashboardShowing = 0
3790
- // make sure the container exists:
3791
- DeviceManager.getInstance().sendCurrentState()
3792
- // set the mic back on if it should be on
3793
- val micEnabled = DeviceStore.get("glasses", "micEnabled") as? Boolean ?: false
3794
- if (micEnabled) {
3795
- restartMic()
3972
+ // Rebuild the page from cached containers, then reconcile against
3973
+ // DeviceManager's authoritative current view so the glasses match the phone
3974
+ // (not just the last-cached G2 containers) after returning from the dashboard.
3975
+ displayScope.launch {
3976
+ displayMutex.withLock { rebuildState() }
3977
+ // set the mic back on if it should be on
3978
+ val micEnabled = DeviceStore.get("glasses", "micEnabled") as? Boolean ?: false
3979
+ if (micEnabled) {
3980
+ restartMic()
3981
+ }
3796
3982
  }
3797
3983
  return
3798
3984
  }
@@ -3828,7 +4014,7 @@ class G2 : SGCManager() {
3828
4014
  // Battery
3829
4015
  (fields[12] as? Int)?.let { battery ->
3830
4016
  if (battery in 0..100) {
3831
- Bridge.log("G2: Battery level: $battery%")
4017
+ // Bridge.log("G2: Battery level: $battery%")
3832
4018
  batteryLevel_ = battery
3833
4019
  }
3834
4020
  }
@@ -3845,13 +4031,13 @@ class G2 : SGCManager() {
3845
4031
  // Software versions
3846
4032
  (fields[5] as? ByteArray)?.let { leftVer ->
3847
4033
  val leftVersion = String(leftVer, Charsets.UTF_8)
3848
- Bridge.log("G2: Left firmware: $leftVersion")
4034
+ // Bridge.log("G2: Left firmware: $leftVersion")
3849
4035
  DeviceStore.apply("glasses", "leftFirmwareVersion", leftVersion)
3850
4036
  }
3851
4037
 
3852
4038
  (fields[6] as? ByteArray)?.let { rightVer ->
3853
4039
  val rightVersion = String(rightVer, Charsets.UTF_8)
3854
- Bridge.log("G2: Right firmware: $rightVersion")
4040
+ // Bridge.log("G2: Right firmware: $rightVersion")
3855
4041
  DeviceStore.apply("glasses", "rightFirmwareVersion", rightVersion)
3856
4042
  DeviceStore.apply("glasses", "firmwareVersion", rightVersion)
3857
4043
  }
@@ -3880,7 +4066,7 @@ class G2 : SGCManager() {
3880
4066
  return
3881
4067
  }
3882
4068
  lastAudioFrame = audioData
3883
- Bridge.log("G2: audio data from $sourceKey: ${data.take(10).joinToString("") { String.format("%02X", it) }}")
4069
+ // Bridge.log("G2: audio data from $sourceKey: ${data.take(10).joinToString("") { String.format("%02X", it) }}")
3884
4070
  DeviceManager.getInstance().handleGlassesMicData(audioData, 40)
3885
4071
  }
3886
4072