@mentra/bluetooth-sdk 0.1.9 → 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 (147) hide show
  1. package/README.md +116 -18
  2. package/android/build.gradle +289 -16
  3. package/android/gradle.properties.example +9 -0
  4. package/android/lc3Lib/build.gradle +95 -6
  5. package/android/settings.gradle +3 -0
  6. package/android/silero/LICENSE.md +17 -0
  7. package/android/silero/build.gradle +42 -0
  8. package/android/silero/proguard-rules.pro +24 -0
  9. package/android/silero/src/androidTest/assets/hello.wav +0 -0
  10. package/android/silero/src/androidTest/java/com/konovalov/vad/silero/VadSileroTest.kt +79 -0
  11. package/android/silero/src/main/assets/LICENSE +21 -0
  12. package/android/silero/src/main/assets/silero_vad.onnx +0 -0
  13. package/android/silero/src/main/java/com/konovalov/vad/silero/Vad.kt +160 -0
  14. package/android/silero/src/main/java/com/konovalov/vad/silero/VadSilero.kt +449 -0
  15. package/android/silero/src/main/java/com/konovalov/vad/silero/config/FrameSize.kt +15 -0
  16. package/android/silero/src/main/java/com/konovalov/vad/silero/config/Mode.kt +14 -0
  17. package/android/silero/src/main/java/com/konovalov/vad/silero/config/SampleRate.kt +12 -0
  18. package/android/silero/src/main/java/com/konovalov/vad/silero/utils/AudioUtils.kt +47 -0
  19. package/android/silero/src/main/java/com/konovalov/vad/silero/utils/TensorMap.kt +41 -0
  20. package/android/silero/src/test/java/com/konovalov/vad/silero/utils/AudioUtilsTest.kt +44 -0
  21. package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkDefaults.kt +6 -0
  22. package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkModule.kt +195 -34
  23. package/android/src/main/java/com/mentra/bluetoothsdk/Bridge.kt +103 -21
  24. package/android/src/main/java/com/mentra/bluetoothsdk/DeviceManager.kt +210 -18
  25. package/android/src/main/java/com/mentra/bluetoothsdk/DeviceStore.kt +30 -2
  26. package/android/src/main/java/com/mentra/bluetoothsdk/MentraBluetoothSdk.kt +787 -85
  27. package/android/src/main/java/com/mentra/bluetoothsdk/audio/AudioModels.kt +6 -2
  28. package/android/src/main/java/com/mentra/bluetoothsdk/camera/CameraModels.kt +125 -11
  29. package/android/src/main/java/com/mentra/bluetoothsdk/controllers/ControllerManager.kt +12 -2
  30. package/android/src/main/java/com/mentra/bluetoothsdk/controllers/R1.kt +8 -2
  31. package/android/src/main/java/com/mentra/bluetoothsdk/debug/BleTraceLogger.kt +33 -17
  32. package/android/src/main/java/com/mentra/bluetoothsdk/events/BluetoothEvents.kt +101 -0
  33. package/android/src/main/java/com/mentra/bluetoothsdk/internal/MapParsing.kt +8 -0
  34. package/android/src/main/java/com/mentra/bluetoothsdk/photoreceiver/LocalPhotoReceiverRegistry.java +108 -0
  35. package/android/src/main/java/com/mentra/bluetoothsdk/photoreceiver/LocalPhotoUploadServer.kt +399 -74
  36. package/android/src/main/java/com/mentra/bluetoothsdk/photoreceiver/MentraPhotoReceiverModule.kt +100 -17
  37. package/android/src/main/java/com/mentra/bluetoothsdk/requests/DisplayRequests.kt +17 -0
  38. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/G1.java +2 -2
  39. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/G2.kt +1293 -758
  40. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/Mach1.java +2 -2
  41. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraLive.java +271 -66
  42. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraNex.kt +197 -149
  43. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraosBle.java +12753 -27036
  44. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/SGCManager.kt +64 -2
  45. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/Simulated.kt +16 -4
  46. package/android/src/main/java/com/mentra/bluetoothsdk/status/DeviceStatus.kt +60 -3
  47. package/android/src/main/java/com/mentra/bluetoothsdk/streaming/StreamModels.kt +12 -3
  48. package/android/src/main/java/com/mentra/bluetoothsdk/stt/STTTools.kt +87 -36
  49. package/android/src/main/java/com/mentra/bluetoothsdk/stt/SherpaOnnxTranscriber.kt +13 -4
  50. package/android/src/main/java/com/mentra/bluetoothsdk/stt/VadGateSpeechPolicy.kt +229 -0
  51. package/android/src/main/java/com/mentra/bluetoothsdk/tts/TTSTools.kt +180 -0
  52. package/android/src/main/java/com/mentra/bluetoothsdk/utils/AvifExifStripper.java +573 -0
  53. package/android/src/main/java/com/mentra/bluetoothsdk/utils/BlePhotoUploadService.java +488 -50
  54. package/android/src/main/java/com/mentra/bluetoothsdk/utils/HeifExifTagReader.java +199 -0
  55. package/android/src/main/java/com/mentra/bluetoothsdk/utils/MessageChunkReassembler.java +18 -1
  56. package/android/src/main/java/com/mentra/bluetoothsdk/utils/MessageChunker.java +64 -18
  57. package/android/src/main/java/com/mentra/bluetoothsdk/utils/NexSGCUtils.kt +48 -39
  58. package/android/src/test/java/com/mentra/bluetoothsdk/camera/PhotoRequestTest.kt +23 -0
  59. package/android/src/test/java/com/mentra/bluetoothsdk/photoreceiver/LocalPhotoReceiverRegistryTest.java +75 -0
  60. package/android/src/test/java/com/mentra/bluetoothsdk/utils/AvifExifStripperTest.java +30 -0
  61. package/android/src/test/java/com/mentra/bluetoothsdk/utils/BlePhotoUploadServiceTest.java +98 -0
  62. package/android/src/test/resources/avif_with_exif.avif +0 -0
  63. package/build/BluetoothSdk.types.d.ts +253 -44
  64. package/build/BluetoothSdk.types.d.ts.map +1 -1
  65. package/build/BluetoothSdk.types.js +11 -10
  66. package/build/BluetoothSdk.types.js.map +1 -1
  67. package/build/_private/BluetoothSdkModule.d.ts +37 -23
  68. package/build/_private/BluetoothSdkModule.d.ts.map +1 -1
  69. package/build/_private/BluetoothSdkModule.js +43 -52
  70. package/build/_private/BluetoothSdkModule.js.map +1 -1
  71. package/build/_private/photoRequestPayload.d.ts +4 -0
  72. package/build/_private/photoRequestPayload.d.ts.map +1 -0
  73. package/build/_private/photoRequestPayload.js +25 -0
  74. package/build/_private/photoRequestPayload.js.map +1 -0
  75. package/build/index.d.ts +1 -1
  76. package/build/index.d.ts.map +1 -1
  77. package/build/index.js +25 -0
  78. package/build/index.js.map +1 -1
  79. package/build/react/useMentraBluetooth.d.ts +2 -2
  80. package/build/react/useMentraBluetooth.d.ts.map +1 -1
  81. package/build/react/useMentraBluetooth.js +3 -3
  82. package/build/react/useMentraBluetooth.js.map +1 -1
  83. package/ios/BluetoothSdkModule.swift +177 -70
  84. package/ios/LocalPhotoUploadServer.swift +370 -105
  85. package/ios/MentraBluetoothSDK.podspec +3 -1
  86. package/ios/MentraPhotoReceiverModule.swift +62 -10
  87. package/ios/Packages/CoreObjC/include/PcmConverter.h +22 -0
  88. package/ios/Packages/SherpaOnnx/SherpaOnnx.swift +668 -26
  89. package/ios/Packages/SherpaOnnx/sherpa-onnx.xcframework/Info.plist +13 -9
  90. package/ios/Packages/SherpaOnnx/sherpa-onnx.xcframework/ios-arm64/Headers/sherpa-onnx/c-api/c-api.h +4551 -0
  91. package/ios/Packages/SherpaOnnx/sherpa-onnx.xcframework/ios-arm64/Headers/sherpa-onnx/c-api/cxx-api.h +1745 -0
  92. package/ios/Packages/SherpaOnnx/sherpa-onnx.xcframework/ios-arm64/libsherpa-onnx.a +0 -0
  93. package/ios/Packages/SherpaOnnx/sherpa-onnx.xcframework/ios-arm64_x86_64-simulator/Headers/sherpa-onnx/c-api/c-api.h +4551 -0
  94. package/ios/Packages/SherpaOnnx/sherpa-onnx.xcframework/ios-arm64_x86_64-simulator/Headers/sherpa-onnx/c-api/cxx-api.h +1745 -0
  95. package/ios/Packages/SherpaOnnx/sherpa-onnx.xcframework/ios-arm64_x86_64-simulator/libsherpa-onnx.a +0 -0
  96. package/ios/Source/BluetoothSdkDefaults.swift +6 -0
  97. package/ios/Source/Bridge.swift +68 -19
  98. package/ios/Source/DeviceManager.swift +167 -32
  99. package/ios/Source/DeviceStore.swift +24 -8
  100. package/ios/Source/MentraBluetoothSDK.swift +803 -47
  101. package/ios/Source/ObservableStore.swift +6 -0
  102. package/ios/Source/{Audio → audio}/AudioModels.swift +9 -7
  103. package/ios/Source/{Camera → camera}/CameraModels.swift +226 -16
  104. package/ios/Source/controllers/ControllerManager.swift +4 -3
  105. package/ios/Source/controllers/R1.swift +3 -3
  106. package/ios/Source/{Events → events}/BluetoothEvents.swift +183 -1
  107. package/ios/Source/{Internal → internal}/ValueParsing.swift +10 -1
  108. package/ios/Source/{Requests → requests}/DisplayRequests.swift +28 -4
  109. package/ios/Source/services/PhoneMic.swift +1 -0
  110. package/ios/Source/sgcs/G1.swift +5 -5
  111. package/ios/Source/sgcs/G2.swift +1494 -870
  112. package/ios/Source/sgcs/Mach1.swift +4 -4
  113. package/ios/Source/sgcs/MentraLive.swift +814 -321
  114. package/ios/Source/sgcs/MentraNex.swift +604 -244
  115. package/ios/Source/sgcs/SGCManager.swift +68 -5
  116. package/ios/Source/sgcs/Simulated.swift +9 -5
  117. package/ios/Source/sgcs/mentraos_ble.pb.swift +3073 -3134
  118. package/ios/Source/status/DeviceStatus.swift +974 -0
  119. package/ios/Source/{Status → status}/WifiHotspotStatus.swift +4 -4
  120. package/ios/Source/{Streaming → streaming}/StreamModels.swift +24 -9
  121. package/ios/Source/stt/STTTools.swift +27 -12
  122. package/ios/Source/stt/SherpaOnnxTranscriber.swift +31 -8
  123. package/ios/Source/tts/TTSTools.swift +164 -0
  124. package/ios/Source/utils/JSCExperiment.swift +7 -7
  125. package/ios/Source/utils/MessageChunkReassembler.swift +20 -1
  126. package/ios/Source/utils/MessageChunker.swift +78 -21
  127. package/ios/Source/utils/TarBz2Extractor.swift +53 -2
  128. package/package.json +1 -1
  129. package/plugin/build/withAndroid.js +68 -4
  130. package/src/BluetoothSdk.types.ts +344 -60
  131. package/src/_private/BluetoothSdkModule.ts +132 -102
  132. package/src/_private/photoRequestPayload.ts +28 -0
  133. package/src/index.ts +61 -15
  134. package/src/react/useMentraBluetooth.ts +6 -4
  135. package/android/.project +0 -28
  136. package/android/lc3Lib/.project +0 -28
  137. package/ios/Packages/SherpaOnnx/sherpa-onnx.xcframework/Headers/cargs.h +0 -162
  138. package/ios/Packages/SherpaOnnx/sherpa-onnx.xcframework/Headers/sherpa-onnx/c-api/c-api.h +0 -1852
  139. package/ios/Packages/SherpaOnnx/sherpa-onnx.xcframework/Headers/sherpa-onnx/c-api/cxx-api.h +0 -674
  140. package/ios/Packages/SherpaOnnx/sherpa-onnx.xcframework/ios-arm64/sherpa-onnx.a +0 -0
  141. package/ios/Packages/SherpaOnnx/sherpa-onnx.xcframework/ios-arm64_x86_64-simulator/sherpa-onnx.a +0 -0
  142. package/ios/Source/Status/DeviceStatus.swift +0 -471
  143. /package/ios/Source/{Connection → connection}/ScanSession.swift +0 -0
  144. /package/ios/Source/{Errors → errors}/BluetoothError.swift +0 -0
  145. /package/ios/Source/{Internal → internal}/BluetoothAvailability.swift +0 -0
  146. /package/ios/Source/{Status → status}/RuntimeState.swift +0 -0
  147. /package/ios/Source/{Types → types}/DeviceModels.swift +0 -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 ----------
@@ -496,16 +565,20 @@ private object DevSettingsProto {
496
565
  return w.toByteArray()
497
566
  }
498
567
 
568
+ /// DevCfgDataPackage with TIME_SYNC command.
569
+ /// TimeSync submessage: f1 = (Unix seconds + TZ offset seconds) as Int32, no TZ field.
570
+ /// Firmware appears to ignore the TZ field, so we pre-shift the timestamp itself
571
+ /// to make UTC interpretation read as local. Empirically confirmed via probe variants in dbg1().
499
572
  fun timeSync(magicRandom: Int): ByteArray {
500
573
  val w = ProtobufWriter()
501
574
  w.writeInt32Field(1, DevCfgCommandId.TIME_SYNC.value)
502
575
  w.writeInt32Field(2, magicRandom)
503
576
 
504
577
  val tsW = ProtobufWriter()
505
- val timestamp = (System.currentTimeMillis() / 1000).toInt()
506
- tsW.writeInt32Field(1, timestamp)
507
- val tz = TimeZone.getDefault().getOffset(System.currentTimeMillis()) / 3600000
508
- tsW.writeInt32Field(2, tz)
578
+ val nowMs = System.currentTimeMillis()
579
+ val nowSec = nowMs / 1000
580
+ val tzSec = (TimeZone.getDefault().getOffset(nowMs) / 1000).toLong()
581
+ tsW.writeInt32Field(1, (nowSec + tzSec).toInt())
509
582
  w.writeMessageField(128, tsW.toByteArray())
510
583
  return w.toByteArray()
511
584
  }
@@ -663,11 +736,23 @@ private object OnboardingProto {
663
736
  // ---------- EvenAI Protobuf Builders (even_ai.proto, service ID 7) ----------
664
737
 
665
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
+ */
666
749
  fun setHeyEven(magicRandom: Int, enabled: Boolean): ByteArray {
667
750
  // EvenAIConfig
668
751
  val configW = ProtobufWriter()
669
- configW.writeInt32Field(1, if (enabled) 1 else 0) // voiceSwitch
670
- 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)
671
756
 
672
757
  // EvenAIDataPackage
673
758
  val w = ProtobufWriter()
@@ -676,6 +761,69 @@ private object EvenAIProto {
676
761
  w.writeMessageField(13, configW.toByteArray()) // config (field 13)
677
762
  return w.toByteArray()
678
763
  }
764
+
765
+ /**
766
+ * EvenAIDataPackage with CTRL — used to put glasses into / out of an AI session.
767
+ * Mirrors Flutter `sendWakeupResp` which sends EvenAIControl{status=ENTER}.
768
+ * status: 1 WAKE_UP, 2 ENTER, 3 EXIT
769
+ */
770
+ fun aiCtrl(magicRandom: Int, status: Int): ByteArray {
771
+ val ctrlW = ProtobufWriter()
772
+ ctrlW.writeInt32Field(1, status) // status
773
+
774
+ val w = ProtobufWriter()
775
+ w.writeInt32Field(1, 1) // commandId = CTRL
776
+ w.writeInt32Field(2, magicRandom)
777
+ w.writeMessageField(3, ctrlW.toByteArray()) // ctrl (field 3)
778
+ return w.toByteArray()
779
+ }
780
+
781
+ /**
782
+ * EvenAIDataPackage with ASK — what the phone sends after cloud ASR resolves the
783
+ * user's audio into text. Mirrors Flutter `sendAsr`: EvenAIAskInfo{text, streamEnable=0}.
784
+ */
785
+ fun aiAsk(magicRandom: Int, text: String, streamEnable: Int = 0): ByteArray {
786
+ val askW = ProtobufWriter()
787
+ askW.writeInt32Field(2, streamEnable) // streamEnable
788
+ askW.writeBytesField(4, text.toByteArray(Charsets.UTF_8)) // text
789
+
790
+ val w = ProtobufWriter()
791
+ w.writeInt32Field(1, 3) // commandId = ASK
792
+ w.writeInt32Field(2, magicRandom)
793
+ w.writeMessageField(5, askW.toByteArray()) // askInfo (field 5)
794
+ return w.toByteArray()
795
+ }
796
+
797
+ /**
798
+ * EvenAIDataPackage with SKILL — triggers a built-in glasses UI the same way
799
+ * "Hey Even, show X" voice command does.
800
+ * skillId values (per even_ai.proto):
801
+ * 0 SKILL_NONE, 1 BRIGHTNESS, 2 TRANSLATE_CTRL, 3 NOTIFICATION,
802
+ * 4 TELEPROMPT, 5 NAVIGATE, 6 CONVERSATE, 7 QUICKLIST, 8 AUTO_BRIGHTNESS
803
+ */
804
+ fun triggerSkill(
805
+ magicRandom: Int,
806
+ skillId: Int,
807
+ skillParam: Int = 0,
808
+ text: String = "",
809
+ streamEnable: Int = 1,
810
+ fTextEnd: Int = 1
811
+ ): ByteArray {
812
+ // EvenAISkillInfo
813
+ val skillW = ProtobufWriter()
814
+ skillW.writeInt32Field(1, streamEnable) // streamEnable
815
+ skillW.writeInt32Field(2, skillId) // skillId
816
+ skillW.writeInt32Field(3, skillParam) // skillParam — for NOTIFICATION skill this is a NotificationType enum
817
+ skillW.writeBytesField(4, text.toByteArray(Charsets.UTF_8)) // text
818
+ skillW.writeInt32Field(6, fTextEnd) // fTextEnd — 1 signals "this is the final/complete packet"
819
+
820
+ // EvenAIDataPackage
821
+ val w = ProtobufWriter()
822
+ w.writeInt32Field(1, 6) // commandId = SKILL
823
+ w.writeInt32Field(2, magicRandom)
824
+ w.writeMessageField(8, skillW.toByteArray()) // skillInfo (field 8)
825
+ return w.toByteArray()
826
+ }
679
827
  }
680
828
 
681
829
  // ---------- Menu Protobuf Builders (menu.proto, service ID 3) ----------
@@ -757,6 +905,124 @@ private object MenuProto {
757
905
  }
758
906
  }
759
907
 
908
+ /**
909
+ * Builders for the dashboard calendar widget (service 0x01, command Dashboard_Receive = 2).
910
+ * Field numbers come from the extracted dashboard.proto v2.1.0_beta_v3 and mirror the iOS
911
+ * DashboardProto.calendarPush builder so both platforms produce the same wire payload.
912
+ */
913
+ private object CalendarProto {
914
+ // Dashboard_Receive — phone → glasses widget/config push.
915
+ const val DASHBOARD_RECEIVE = 2
916
+
917
+ /**
918
+ * Build a Schedule submessage (the calendar event payload).
919
+ * f1 = scheduleId (int32, required)
920
+ * f2 = title (string, optional)
921
+ * f3 = location (string, optional)
922
+ * f4 = time (string, optional — display text e.g. "10:00 AM")
923
+ * f5 = endTimestamp (int32, Unix seconds — pre-shifted by TZ so the glasses,
924
+ * which treat timestamps as already-local, display local time)
925
+ */
926
+ private fun schedule(
927
+ scheduleId: Int,
928
+ title: String?,
929
+ location: String?,
930
+ time: String?,
931
+ endTimestamp: Int
932
+ ): ByteArray {
933
+ val w = ProtobufWriter()
934
+ w.writeInt32Field(1, scheduleId)
935
+ title?.let { w.writeStringField(2, it) }
936
+ location?.let { w.writeStringField(3, it) }
937
+ time?.let { w.writeStringField(4, it) }
938
+ w.writeInt32Field(5, endTimestamp)
939
+ return w.toByteArray()
940
+ }
941
+
942
+ /**
943
+ * Build the full calendar-push DashboardDataPackage:
944
+ * DashboardDataPackage {
945
+ * commandId = Dashboard_Receive (2)
946
+ * magicRandom
947
+ * dashboardReceive = DashboardReceiveFromApp {
948
+ * packageId = 1
949
+ * bashboardConfig = DashboardContent {
950
+ * widgetComponents = rWidgetComponent {
951
+ * schedule = rScheduleWidget {
952
+ * scheduleTotal, scheduleNum (0-based), Schedule, scheduleAuthority
953
+ * }
954
+ * }
955
+ * }
956
+ * }
957
+ * }
958
+ */
959
+ fun calendarPush(
960
+ magicRandom: Int,
961
+ packageId: Int,
962
+ scheduleId: Int,
963
+ title: String?,
964
+ location: String?,
965
+ time: String?,
966
+ endTimestamp: Int,
967
+ scheduleAuthority: Int,
968
+ scheduleTotal: Int,
969
+ scheduleNum: Int
970
+ ): ByteArray {
971
+ val sched = schedule(scheduleId, title, location, time, endTimestamp)
972
+
973
+ // rScheduleWidget { f1 = scheduleTotal, f2 = scheduleNum, f3 = Schedule, f4 = scheduleAuthority }
974
+ val rSchedW = ProtobufWriter()
975
+ rSchedW.writeInt32Field(1, scheduleTotal)
976
+ rSchedW.writeInt32Field(2, scheduleNum)
977
+ rSchedW.writeMessageField(3, sched)
978
+ rSchedW.writeInt32Field(4, scheduleAuthority)
979
+
980
+ // rWidgetComponent { f3 = rScheduleWidget }
981
+ val rWidgetW = ProtobufWriter()
982
+ rWidgetW.writeMessageField(3, rSchedW.toByteArray())
983
+
984
+ // DashboardContent { f2 = rWidgetComponent }
985
+ val contentW = ProtobufWriter()
986
+ contentW.writeMessageField(2, rWidgetW.toByteArray())
987
+
988
+ // DashboardReceiveFromApp { f1 = packageId, f3 = DashboardContent }
989
+ val receiveW = ProtobufWriter()
990
+ receiveW.writeInt32Field(1, packageId)
991
+ receiveW.writeMessageField(3, contentW.toByteArray())
992
+
993
+ // DashboardDataPackage { f1 = commandId, f2 = magicRandom, f4 = dashboardReceive }
994
+ val pkgW = ProtobufWriter()
995
+ pkgW.writeInt32Field(1, DASHBOARD_RECEIVE)
996
+ pkgW.writeInt32Field(2, magicRandom)
997
+ pkgW.writeMessageField(4, receiveW.toByteArray())
998
+ return pkgW.toByteArray()
999
+ }
1000
+
1001
+ fun calendarClear(magicRandom: Int, packageId: Int, scheduleAuthority: Int): ByteArray {
1002
+ // rScheduleWidget with scheduleTotal=0 clears the widget without sending a stale Schedule.
1003
+ val rSchedW = ProtobufWriter()
1004
+ rSchedW.writeInt32Field(1, 0)
1005
+ rSchedW.writeInt32Field(2, 0)
1006
+ rSchedW.writeInt32Field(4, scheduleAuthority)
1007
+
1008
+ val rWidgetW = ProtobufWriter()
1009
+ rWidgetW.writeMessageField(3, rSchedW.toByteArray())
1010
+
1011
+ val contentW = ProtobufWriter()
1012
+ contentW.writeMessageField(2, rWidgetW.toByteArray())
1013
+
1014
+ val receiveW = ProtobufWriter()
1015
+ receiveW.writeInt32Field(1, packageId)
1016
+ receiveW.writeMessageField(3, contentW.toByteArray())
1017
+
1018
+ val pkgW = ProtobufWriter()
1019
+ pkgW.writeInt32Field(1, DASHBOARD_RECEIVE)
1020
+ pkgW.writeInt32Field(2, magicRandom)
1021
+ pkgW.writeMessageField(4, receiveW.toByteArray())
1022
+ return pkgW.toByteArray()
1023
+ }
1024
+ }
1025
+
760
1026
  // ---------- EvenBLE Transport Layer ----------
761
1027
 
762
1028
  private object EvenBLETransport {
@@ -1041,7 +1307,6 @@ class G2 : SGCManager() {
1041
1307
  private val EVEN_HUB_QUEUE_TICK_MS = 100L
1042
1308
  private var startupPageCreated: Boolean = false
1043
1309
  private var pageCreated: Boolean = false
1044
- private var pageHasTextContainer: Boolean = false
1045
1310
  private var currentTextContent: String = ""
1046
1311
  private var textContainerID: Int = 1
1047
1312
  private var imageSessionCounter: Int = 0
@@ -1050,13 +1315,103 @@ class G2 : SGCManager() {
1050
1315
  private var leftAuthenticated: Boolean = false
1051
1316
  private var rightAuthenticated: Boolean = false
1052
1317
  private var currentBitmapBase64: String = ""
1318
+ private var dashboardShowing = 0
1053
1319
 
1054
1320
  // Dashboard menu state
1055
1321
  private var menuAppIdToPackageName: MutableMap<Int, String> = mutableMapOf()
1056
1322
  private var dashboardMenuItems: MutableList<MenuProto.MenuItem> = mutableListOf()
1057
1323
  private var activeMenuAppId: Int? = null
1058
1324
  private var lastClickTimestamp: Long? = null
1325
+ private var lastEvenHubResponseTimestamp: Long? = null
1059
1326
  private var lastMenuSelectTimestamp: Long? = null
1327
+ private var lastGestureCtrlTimestamp: Long? = null
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
+
1337
+ /** A tracked image container on the current page. Keyed by its rect for reuse. */
1338
+ private data class ImgContainer(
1339
+ val id: Int,
1340
+ val x: Int,
1341
+ val y: Int,
1342
+ val width: 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
1348
+ ) {
1349
+ val name: String
1350
+ get() = "img-$id"
1351
+
1352
+ fun matches(x: Int, y: Int, width: Int, height: Int): Boolean =
1353
+ this.x == x && this.y == y && this.width == width && this.height == height
1354
+ }
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
+
1392
+ /**
1393
+ * Live list of image containers on the page, ordered oldest→newest (for LRU eviction). The page
1394
+ * may hold at most 4 image containers (IDs from the pool below).
1395
+ */
1396
+ private val imageContainers: MutableList<ImgContainer> = mutableListOf()
1397
+ private val textContainers: MutableList<TextContainer> = mutableListOf()
1398
+ /** Fixed pool of container IDs the page protocol expects. */
1399
+ private val imageContainerIDPool: List<Int> = listOf(10, 11, 12, 13)
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
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
1060
1415
 
1061
1416
  // Battery state
1062
1417
  private var _batteryLevel: Int = -1
@@ -1114,6 +1469,7 @@ class G2 : SGCManager() {
1114
1469
  left: Boolean = false,
1115
1470
  right: Boolean = true
1116
1471
  ) {
1472
+ // Bridge.log("G2: sendToGlasses() - sending ${packets.size} packets first byte: ${packets[0][0]}")
1117
1473
  if (packets.isEmpty()) return
1118
1474
  // Single-packet sends (the common case for text/settings) go straight through.
1119
1475
  if (packets.size == 1) {
@@ -1137,7 +1493,7 @@ class G2 : SGCManager() {
1137
1493
  payload = payload,
1138
1494
  reserveFlag = true
1139
1495
  )
1140
- sendToGlasses(packets)
1496
+ sendToGlasses(packets, left = true, right = true)
1141
1497
  }
1142
1498
 
1143
1499
  private fun sendDevSettingsCommand(
@@ -1153,6 +1509,16 @@ class G2 : SGCManager() {
1153
1509
  sendToGlasses(packets, left = left, right = right)
1154
1510
  }
1155
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
+
1156
1522
  private fun sendG2SettingCommand(payload: ByteArray) {
1157
1523
  val packets =
1158
1524
  sendManager.buildPackets(
@@ -1225,7 +1591,7 @@ class G2 : SGCManager() {
1225
1591
 
1226
1592
  // ---------- Authentication Sequence ----------
1227
1593
 
1228
- private fun runAuthSequence() {
1594
+ private suspend fun runAuthSequence() {
1229
1595
  Bridge.log("G2: Running auth sequence")
1230
1596
 
1231
1597
  // Auth to left side
@@ -1235,457 +1601,160 @@ class G2 : SGCManager() {
1235
1601
  }
1236
1602
 
1237
1603
  // Small delay then auth right + pipe role change + time sync
1238
- mainHandler.postDelayed(
1239
- {
1240
- val authR = DevSettingsProto.authCmd(sendManager.nextMagicRandom())
1241
- sendDevSettingsCommand(authR, left = false, right = true)
1242
-
1243
- mainHandler.postDelayed(
1244
- {
1245
- val roleChange =
1246
- DevSettingsProto.pipeRoleChange(
1247
- sendManager.nextMagicRandom()
1248
- )
1249
- sendDevSettingsCommand(roleChange, left = false, right = true)
1250
-
1251
- mainHandler.postDelayed(
1252
- {
1253
- val timeSync =
1254
- DevSettingsProto.timeSync(
1255
- sendManager.nextMagicRandom()
1256
- )
1257
- sendDevSettingsCommand(timeSync)
1258
-
1259
- // Skip onboarding on connect
1260
- mainHandler.postDelayed(
1261
- {
1262
- val onboarding =
1263
- OnboardingProto.skipOnboarding(
1264
- sendManager
1265
- .nextMagicRandom()
1266
- )
1267
- sendOnboardingCommand(onboarding)
1268
- Bridge.log(
1269
- "G2: Sent onboarding skip (FINISH)"
1270
- )
1271
-
1272
- // Disable "Hey Even" wakeword on connect
1273
- val heyEvenOff =
1274
- EvenAIProto.setHeyEven(
1275
- sendManager
1276
- .nextMagicRandom(),
1277
- false
1278
- )
1279
- sendEvenAICommand(heyEvenOff)
1280
- Bridge.log("G2: Disabled Hey Even wakeword")
1281
-
1282
- // Replicate Even app's full init sequence
1283
- // for menu selection support:
1284
-
1285
- // 0. Universe settings (g2_setting cmd=1
1286
- // field3 with field9=universe settings)
1287
- val univW = ProtobufWriter()
1288
- univW.writeInt32Field(
1289
- 1,
1290
- 1
1291
- ) // DeviceReceiveInfo
1292
- univW.writeInt32Field(
1293
- 2,
1294
- sendManager.nextMagicRandom()
1295
- )
1296
- univW.writeMessageField(
1297
- 3,
1298
- byteArrayOf(
1299
- 0x4A,
1300
- 0x0A, // field 9, length 10
1301
- 0x08,
1302
- 0x00, // unitFormat=0
1303
- 0x10,
1304
- 0x00, // distanceUnit=0
1305
- 0x18,
1306
- 0x01, // timeFormat=1
1307
- 0x20,
1308
- 0x00, // dateFormat=0
1309
- 0x28,
1310
- 0x01 // temperatureUnit=1
1311
- )
1312
- )
1313
- sendG2SettingCommand(univW.toByteArray())
1314
-
1315
- // 1. gesture_ctrl init (field1=0,
1316
- // field2=magicRandom)
1317
- val gestureInitW = ProtobufWriter()
1318
- gestureInitW.writeInt32Field(1, 0)
1319
- gestureInitW.writeInt32Field(
1320
- 2,
1321
- sendManager.nextMagicRandom()
1322
- )
1323
- sendGestureCtrlCommand(
1324
- gestureInitW.toByteArray()
1325
- )
1326
-
1327
- // 2. ui_setting_app (0x0C) — query
1328
- val uiSettW = ProtobufWriter()
1329
- uiSettW.writeInt32Field(
1330
- 1,
1331
- 2
1332
- ) // cmd = DeviceReceiveRequest
1333
- uiSettW.writeInt32Field(
1334
- 2,
1335
- sendManager.nextMagicRandom()
1336
- )
1337
- uiSettW.writeMessageField(
1338
- 4,
1339
- byteArrayOf(0x08, 0x01, 0x10, 0x00)
1340
- ) // {1:1, 2:0}
1341
- sendToGlasses(
1342
- sendManager.buildPackets(
1343
- serviceId = 0x0C,
1344
- payload =
1345
- uiSettW.toByteArray(),
1346
- reserveFlag = true
1347
- )
1348
- )
1349
-
1350
- // 3. teleprompter (0x10) — config (cmd=1,
1351
- // field3={1:4})
1352
- val teleW = ProtobufWriter()
1353
- teleW.writeInt32Field(1, 1)
1354
- teleW.writeInt32Field(
1355
- 2,
1356
- sendManager.nextMagicRandom()
1357
- )
1358
- teleW.writeMessageField(
1359
- 3,
1360
- byteArrayOf(0x08, 0x04)
1361
- ) // {1:4}
1362
- sendToGlasses(
1363
- sendManager.buildPackets(
1364
- serviceId = 0x10,
1365
- payload =
1366
- teleW.toByteArray(),
1367
- reserveFlag = true
1368
- )
1369
- )
1370
-
1371
- // 4. EvenHub CTRL on service 0x81 (cmd=1,
1372
- // empty field3)
1373
- val ehCtrlW = ProtobufWriter()
1374
- ehCtrlW.writeInt32Field(1, 1)
1375
- ehCtrlW.writeInt32Field(
1376
- 2,
1377
- sendManager.nextMagicRandom()
1378
- )
1379
- ehCtrlW.writeMessageField(3, ByteArray(0))
1380
- sendEvenHubCtrlCommand(
1381
- ehCtrlW.toByteArray()
1382
- )
1383
-
1384
- // 5. calendar (0x04) — config
1385
- val calW = ProtobufWriter()
1386
- calW.writeInt32Field(1, 1)
1387
- calW.writeInt32Field(
1388
- 2,
1389
- sendManager.nextMagicRandom()
1390
- )
1391
- calW.writeMessageField(
1392
- 3,
1393
- byteArrayOf(
1394
- 0x08,
1395
- 0x01,
1396
- 0x10,
1397
- 0x01,
1398
- 0x18,
1399
- 0x05,
1400
- 0x28,
1401
- 0x01
1402
- )
1403
- )
1404
- sendToGlasses(
1405
- sendManager.buildPackets(
1406
- serviceId = 0x04,
1407
- payload =
1408
- calW.toByteArray(),
1409
- reserveFlag = true
1410
- )
1411
- )
1412
-
1413
- // 6. Dashboard init (0x01) — display
1414
- // settings
1415
- val dashDisplayW = ProtobufWriter()
1416
- dashDisplayW.writeInt32Field(
1417
- 1,
1418
- 4
1419
- ) // displayMode
1420
- dashDisplayW.writeInt32Field(
1421
- 2,
1422
- 3
1423
- ) // statusDisplayCount
1424
- dashDisplayW.writeMessageField(
1425
- 3,
1426
- byteArrayOf(1, 2, 3)
1427
- ) // statusDisplayOrder
1428
- dashDisplayW.writeInt32Field(
1429
- 4,
1430
- 4
1431
- ) // widgetDisplayCount
1432
- dashDisplayW.writeMessageField(
1433
- 5,
1434
- byteArrayOf(1, 3, 2, 2)
1435
- ) // widgetDisplayOrder
1436
- dashDisplayW.writeInt32Field(
1437
- 6,
1438
- 1
1439
- ) // halfDayFormat
1440
- dashDisplayW.writeInt32Field(
1441
- 7,
1442
- 1
1443
- ) // temperatureUnit
1444
-
1445
- val dashRecvW = ProtobufWriter()
1446
- dashRecvW.writeMessageField(
1447
- 2,
1448
- dashDisplayW.toByteArray()
1449
- )
1450
-
1451
- val dashPkgW = ProtobufWriter()
1452
- dashPkgW.writeInt32Field(
1453
- 1,
1454
- 2
1455
- ) // Dashboard_Receive
1456
- dashPkgW.writeInt32Field(
1457
- 2,
1458
- sendManager.nextMagicRandom()
1459
- )
1460
- dashPkgW.writeMessageField(
1461
- 4,
1462
- dashRecvW.toByteArray()
1463
- )
1464
- sendDashboardCommand(dashPkgW.toByteArray())
1465
-
1466
- // 7. Dashboard REQUEST_NEWS_INFO (cmd=5,
1467
- // field7={1:1})
1468
- val dashNewsReqW = ProtobufWriter()
1469
- dashNewsReqW.writeInt32Field(
1470
- 1,
1471
- 5
1472
- ) // REQUEST_NEWS_INFO
1473
- dashNewsReqW.writeInt32Field(
1474
- 2,
1475
- sendManager.nextMagicRandom()
1476
- )
1477
- dashNewsReqW.writeMessageField(
1478
- 7,
1479
- byteArrayOf(0x08, 0x01)
1480
- ) // {1:1}
1481
- sendDashboardCommand(
1482
- dashNewsReqW.toByteArray()
1483
- )
1484
-
1485
- // 8. Gesture control list via g2_setting
1486
- val gestListW = ProtobufWriter()
1487
- gestListW.writeInt32Field(
1488
- 1,
1489
- 1
1490
- ) // DeviceReceiveInfo
1491
- gestListW.writeInt32Field(
1492
- 2,
1493
- sendManager.nextMagicRandom()
1494
- )
1495
- // field 3 with field 10
1496
- // (gestureControlList): 3 items, all
1497
- // app_unable
1498
- val gestureCtrlPayload =
1499
- byteArrayOf(
1500
- 0x52,
1501
- 0x18, // field 10, length 24
1502
- 0x0A,
1503
- 0x06,
1504
- 0x08,
1505
- 0x00,
1506
- 0x10,
1507
- 0x00,
1508
- 0x18,
1509
- 0x00, // item 1
1510
- 0x0A,
1511
- 0x06,
1512
- 0x08,
1513
- 0x00,
1514
- 0x10,
1515
- 0x01,
1516
- 0x18,
1517
- 0x00, // item 2
1518
- 0x0A,
1519
- 0x06,
1520
- 0x08,
1521
- 0x00,
1522
- 0x10,
1523
- 0x02,
1524
- 0x18,
1525
- 0x00 // item 3
1526
- )
1527
- gestListW.writeMessageField(
1528
- 3,
1529
- gestureCtrlPayload
1530
- )
1531
- sendG2SettingCommand(
1532
- gestListW.toByteArray()
1533
- )
1534
-
1535
- // 9. Dashboard APP_REQUEST_NEWS_INFO
1536
- // (cmd=7, field9={1:1})
1537
- val dashAppNewsW = ProtobufWriter()
1538
- dashAppNewsW.writeInt32Field(
1539
- 1,
1540
- 7
1541
- ) // APP_REQUEST_NEWS_INFO
1542
- dashAppNewsW.writeInt32Field(
1543
- 2,
1544
- sendManager.nextMagicRandom()
1545
- )
1546
- dashAppNewsW.writeMessageField(
1547
- 9,
1548
- byteArrayOf(0x08, 0x01)
1549
- ) // {1:1}
1550
- sendDashboardCommand(
1551
- dashAppNewsW.toByteArray()
1552
- )
1553
-
1554
- Bridge.log(
1555
- "G2: Sent full Even-compatible init sequence"
1556
- )
1557
- },
1558
- 200
1559
- )
1560
-
1561
- // Start heartbeats after auth
1562
- startHeartbeats()
1563
-
1564
- // Mark as ready and request device info
1565
- mainHandler.postDelayed(
1566
- {
1567
- reconnectionManager.stop()
1568
- Bridge.log(
1569
- "G2: Auth sequence complete, glasses ready"
1570
- )
1571
-
1572
- // Set device_name so DeviceManager can save
1573
- // it for reconnection
1574
- val peripheralName =
1575
- rightGatt?.device?.name
1576
- ?: leftGatt?.device?.name
1577
- val serialNumber =
1578
- peripheralName?.let {
1579
- deviceNameToSerialNumber[it]
1580
- }
1581
- if (serialNumber != null) {
1582
- DeviceStore.apply(
1583
- "bluetooth",
1584
- "device_name",
1585
- serialNumber
1586
- )
1587
- Bridge.log(
1588
- "G2: Set device_name to $serialNumber"
1589
- )
1590
- }
1591
-
1592
- // Set bluetooth name and device model for
1593
- // Device Info page
1594
- val btName =
1595
- rightGatt?.device?.name
1596
- ?: leftGatt?.device?.name
1597
- ?: ""
1598
- DeviceStore.apply(
1599
- "glasses",
1600
- "bluetoothName",
1601
- btName
1602
- )
1603
- DeviceStore.apply(
1604
- "glasses",
1605
- "deviceModel",
1606
- DeviceTypes.G2
1607
- )
1608
-
1609
- setFullyConnected()
1610
-
1611
- // Connect a controller if we have one
1612
- connectController()
1613
-
1614
- // Query version + battery info from glasses
1615
- requestDeviceInfo()
1616
-
1617
- sendMenuApps()
1618
- },
1619
- 500
1620
- )
1621
- },
1622
- 200
1623
- )
1624
- },
1625
- 200
1626
- )
1627
- },
1628
- 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
+ )
1629
1639
  )
1640
+
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
1656
+
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
+ }
1630
1711
  }
1631
1712
 
1632
- private fun runDashboardSequence() {
1633
- Bridge.log("G2: Running dashboard sequence")
1713
+ private fun dashboardHalfDayFormat(): Int {
1714
+ val twelveHour = DeviceStore.get("bluetooth", "twelve_hour_time") as? Boolean ?: true
1715
+ return if (twelveHour) 1 else 0
1716
+ }
1634
1717
 
1635
- // Send the shutdown command to the glasses
1636
- val msg = EvenHubProto.shutdownMessage()
1637
- sendEvenHubCommand(msg)
1638
- pageCreated = false
1639
- currentTextContent = ""
1640
-
1641
- mainHandler.postDelayed(
1642
- {
1643
- // 1. gesture_ctrl init (field1=0, field2=magicRandom)
1644
- val gestureInitW = ProtobufWriter()
1645
- gestureInitW.writeInt32Field(1, 0)
1646
- gestureInitW.writeInt32Field(2, sendManager.nextMagicRandom())
1647
- sendGestureCtrlCommand(gestureInitW.toByteArray())
1648
-
1649
- // 6. Dashboard init (0x01) — display settings
1650
- val dashDisplayW = ProtobufWriter()
1651
- dashDisplayW.writeInt32Field(1, 4) // displayMode
1652
- dashDisplayW.writeInt32Field(2, 3) // statusDisplayCount
1653
- dashDisplayW.writeMessageField(3, byteArrayOf(1, 2, 3)) // statusDisplayOrder
1654
- dashDisplayW.writeInt32Field(4, 4) // widgetDisplayCount
1655
- dashDisplayW.writeMessageField(5, byteArrayOf(1, 3, 2, 2)) // widgetDisplayOrder
1656
- dashDisplayW.writeInt32Field(6, 1) // halfDayFormat
1657
- dashDisplayW.writeInt32Field(7, 1) // temperatureUnit
1658
-
1659
- val dashRecvW = ProtobufWriter()
1660
- dashRecvW.writeMessageField(2, dashDisplayW.toByteArray())
1661
-
1662
- val dashPkgW = ProtobufWriter()
1663
- dashPkgW.writeInt32Field(1, 2) // Dashboard_Receive
1664
- dashPkgW.writeInt32Field(2, sendManager.nextMagicRandom())
1665
- dashPkgW.writeMessageField(4, dashRecvW.toByteArray())
1666
- sendDashboardCommand(dashPkgW.toByteArray())
1667
-
1668
- Bridge.log("G2: Sent full Even-compatible init sequence")
1669
- },
1670
- 1000
1671
- )
1718
+ private fun dashboardTemperatureUnit(): Int {
1719
+ val metric = DeviceStore.get("bluetooth", "metric_system") as? Boolean ?: false
1720
+ return if (metric) 1 else 2
1721
+ }
1722
+
1723
+ override fun sendDashboardDisplaySettings() {
1724
+ val dashDisplayW = ProtobufWriter()
1725
+ dashDisplayW.writeInt32Field(1, 4) // displayMode
1726
+ dashDisplayW.writeInt32Field(2, 3) // statusDisplayCount
1727
+ dashDisplayW.writeMessageField(3, byteArrayOf(1, 2, 3)) // statusDisplayOrder
1728
+ dashDisplayW.writeInt32Field(4, 4) // widgetDisplayCount
1729
+ dashDisplayW.writeMessageField(5, byteArrayOf(1, 3, 2, 2)) // widgetDisplayOrder
1730
+ dashDisplayW.writeInt32Field(6, dashboardHalfDayFormat()) // halfDayFormat
1731
+ dashDisplayW.writeInt32Field(7, dashboardTemperatureUnit()) // temperatureUnit
1732
+
1733
+ val dashRecvW = ProtobufWriter()
1734
+ dashRecvW.writeMessageField(2, dashDisplayW.toByteArray())
1735
+
1736
+ val dashPkgW = ProtobufWriter()
1737
+ dashPkgW.writeInt32Field(1, 2) // Dashboard_Receive
1738
+ dashPkgW.writeInt32Field(2, sendManager.nextMagicRandom())
1739
+ dashPkgW.writeMessageField(4, dashRecvW.toByteArray())
1740
+ sendDashboardCommand(dashPkgW.toByteArray())
1672
1741
  }
1673
1742
 
1674
1743
  // ---------- Heartbeats ----------
1675
1744
 
1676
1745
  private fun startHeartbeats() {
1677
- // EvenHub heartbeat every 5 seconds
1746
+ // EvenHub heartbeat every 10 seconds
1678
1747
  stopHeartbeats()
1679
1748
 
1680
1749
  val hbRunnable =
1681
1750
  object : Runnable {
1682
1751
  override fun run() {
1683
1752
  sendEvenHubHeartbeat()
1684
- mainHandler.postDelayed(this, 5000)
1753
+ mainHandler.postDelayed(this, 10000)
1685
1754
  }
1686
1755
  }
1687
1756
  heartbeatRunnable = hbRunnable
1688
- mainHandler.postDelayed(hbRunnable, 5000)
1757
+ mainHandler.postDelayed(hbRunnable, 10000)
1689
1758
 
1690
1759
  // DevSettings heartbeat every 5 seconds
1691
1760
  val dsRunnable =
@@ -1760,157 +1829,335 @@ class G2 : SGCManager() {
1760
1829
  }
1761
1830
  }
1762
1831
 
1832
+ private fun sendStoredCalendarEvents() {
1833
+ val calendarEvents =
1834
+ DeviceStore.get("bluetooth", "calendar_events") as? List<Map<String, Any>>
1835
+ ?: emptyList()
1836
+ sendCalendarEvents(calendarEvents)
1837
+ }
1838
+
1763
1839
  // ---------- SGCManager: Display Control ----------
1764
1840
 
1765
1841
  override fun sendTextWall(text: String) {
1766
- // 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
+ }
1767
1858
 
1768
- if (text.isEmpty()) {
1769
- clearDisplay()
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)}...)")
1871
+
1872
+ // ignore events while the ER dashboard is open:
1873
+ val useNativeDashboard =
1874
+ DeviceStore.get("bluetooth", "use_native_dashboard") as? Boolean ?: false
1875
+ if (useNativeDashboard && dashboardShowing > 0) {
1770
1876
  return
1771
1877
  }
1772
1878
 
1773
- if (!pageCreated || !pageHasTextContainer) {
1774
- createPageWithText(text)
1775
- } else {
1776
- updateText(text)
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
1888
+
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
1777
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()
1778
1937
  }
1779
1938
 
1780
1939
  override fun sendDoubleTextWall(top: String, bottom: String) {
1781
- 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"
1782
1943
  sendTextWall(combined)
1783
1944
  }
1784
1945
 
1785
1946
  override fun clearDisplay() {
1786
1947
  Bridge.log("G2: clearDisplay()")
1787
- if (pageCreated) {
1788
- 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
+ }
1789
1966
  }
1790
1967
  }
1791
1968
 
1792
- override fun displayBitmap(base64ImageData: String): Boolean {
1793
- currentBitmapBase64 = base64ImageData
1794
- currentTextContent = ""
1795
- return displayBitmapQuad(base64ImageData)
1796
- }
1969
+ /**
1970
+ * Display a bitmap inside a positioned image container.
1971
+ *
1972
+ * The page keeps a live list of up to 4 image containers keyed by exact rect:
1973
+ * - If a container with the requested rect already exists, the image is just resent to it (no
1974
+ * page rebuild).
1975
+ * - Otherwise a new container is added (evicting the oldest when the list would exceed 4) and
1976
+ * the page is rebuilt before the image is sent.
1977
+ *
1978
+ * Omitted params default to a 100x100 container in the top-left corner.
1979
+ */
1980
+ override fun displayBitmap(
1981
+ base64ImageData: String,
1982
+ x: Int?,
1983
+ y: Int?,
1984
+ width: Int?,
1985
+ height: Int?
1986
+ ): Boolean {
1987
+ val rx = x ?: defaultImgX
1988
+ val ry = y ?: defaultImgY
1989
+ val rw = width ?: defaultImgWidth
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
+ }
1797
1997
 
1798
- private fun displayBitmapQuad(base64ImageData: String): Boolean {
1799
1998
  val rawData =
1800
1999
  Base64.decode(base64ImageData, Base64.DEFAULT)
1801
2000
  ?: run {
1802
- Bridge.log("G2: displayBitmapQuad() - failed to decode base64")
2001
+ Bridge.log("G2: displayBitmap() - failed to decode base64")
1803
2002
  return false
1804
2003
  }
1805
2004
 
1806
- val tiles =
1807
- renderAndSliceTo4Tiles(rawData)
2005
+ val bmpData =
2006
+ convertToG2Bmp(rawData, containerWidth = rw, containerHeight = rh)
1808
2007
  ?: run {
1809
- Bridge.log(
1810
- "G2: displayBitmapQuad() - failed to slice image into tiles"
1811
- )
2008
+ Bridge.log("G2: displayBitmap() - failed to convert image to BMP")
1812
2009
  return false
1813
2010
  }
1814
2011
 
1815
- // 2x2 grid of 200x100 tiles covering 400x200 (matches G2.swift:1729-1745)
1816
- val container1 =
1817
- EvenHubProto.imageContainerProperty(
1818
- x = 0,
1819
- y = 0,
1820
- width = 200,
1821
- height = 100,
1822
- containerID = 10,
1823
- containerName = "img-10"
1824
- )
1825
- val container2 =
1826
- EvenHubProto.imageContainerProperty(
1827
- x = 200,
1828
- y = 0,
1829
- width = 200,
1830
- height = 100,
1831
- containerID = 11,
1832
- containerName = "img-11"
1833
- )
1834
- val container3 =
1835
- EvenHubProto.imageContainerProperty(
1836
- x = 0,
1837
- y = 100,
1838
- width = 200,
1839
- height = 100,
1840
- containerID = 12,
1841
- containerName = "img-12"
1842
- )
1843
- val container4 =
1844
- EvenHubProto.imageContainerProperty(
1845
- x = 200,
1846
- y = 100,
1847
- width = 200,
1848
- height = 100,
1849
- containerID = 13,
1850
- containerName = "img-13"
1851
- )
1852
- val containers = listOf(container1, container2, container3, container4)
1853
-
1854
- val msg: ByteArray =
1855
- if (!startupPageCreated) {
1856
- startupPageCreated = true
1857
- EvenHubProto.createPageMessage(
1858
- imageContainers = containers,
1859
- magicRandom = sendManager.nextMagicRandom(),
1860
- appId = activeMenuAppId
1861
- )
1862
- } else {
1863
- EvenHubProto.rebuildPageMessage(
1864
- imageContainers = containers,
1865
- magicRandom = sendManager.nextMagicRandom(),
1866
- appId = activeMenuAppId
1867
- )
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
+ }
1868
2026
  }
1869
- sendEvenHubCommand(msg)
1870
- pageCreated = true
1871
- pageHasTextContainer = false
1872
- currentTextContent = ""
1873
-
1874
- // After the 1s settle delay iOS waits, send each tile's BMP in series. Android's
1875
- // displayBitmap signature is synchronous Boolean, so this is fire-and-forget — we
1876
- // chain tiles via callbacks rather than awaiting like iOS.
1877
- Bridge.log("G2: displayBitmapQuad() - page sent, scheduling fragment send in 1s...")
1878
- mainHandler.postDelayed(
1879
- {
1880
- sendImageDataChained(
1881
- tiles =
1882
- listOf(
1883
- Triple(10, "img-10", tiles[0]),
1884
- Triple(11, "img-11", tiles[1]),
1885
- Triple(12, "img-12", tiles[2]),
1886
- Triple(13, "img-13", tiles[3])
1887
- ),
1888
- index = 0
1889
- )
1890
- },
1891
- 1000
1892
- )
2027
+ }
2028
+ return true
2029
+ } else {
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
+ }
2037
+ }
1893
2038
 
1894
2039
  return true
1895
2040
  }
1896
2041
 
1897
- /** Send the next tile's BMP in series; recurse to the next tile after this one finishes. */
1898
- private fun sendImageDataChained(
1899
- tiles: List<Triple<Int, String, ByteArray>>,
1900
- index: Int
1901
- ) {
1902
- if (index >= tiles.size) return
1903
- val (containerID, containerName, bmpData) = tiles[index]
1904
- sendImageData(containerID, containerName, bmpData) {
1905
- sendImageDataChained(tiles, index + 1)
1906
- }
2042
+ /**
2043
+ * Add a new image container for the rect, evicting the oldest when the list is full (max 4).
2044
+ * Returns the newly tracked container (with an assigned ID from the pool).
2045
+ */
2046
+ private fun addImageContainer(
2047
+ x: Int,
2048
+ y: Int,
2049
+ width: Int,
2050
+ height: Int,
2051
+ bmpData: ByteArray
2052
+ ): ImgContainer {
2053
+ // Evict the oldest container when at capacity, freeing its ID for reuse.
2054
+ if (imageContainers.size >= imageContainerIDPool.size) {
2055
+ val evicted = imageContainers.removeAt(0)
2056
+ Bridge.log("G2: evicting oldest image container ${evicted.id}")
2057
+ }
2058
+ // Pick the lowest free ID from the pool.
2059
+ val usedIDs = imageContainers.map { it.id }.toSet()
2060
+ val id = imageContainerIDPool.firstOrNull { it !in usedIDs } ?: imageContainerIDPool[0]
2061
+ val container =
2062
+ ImgContainer(id = id, x = x, y = y, width = width, height = height, bmpData = bmpData)
2063
+ imageContainers.add(container)
2064
+ return container
2065
+ }
2066
+
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
1907
2101
  }
1908
2102
 
1909
- private fun sendImageData(
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
+ // }
2148
+ }
2149
+
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(
1910
2158
  containerID: Int,
1911
2159
  containerName: String,
1912
- bmpData: ByteArray,
1913
- onComplete: (() -> Unit)? = null
2160
+ bmpData: ByteArray
1914
2161
  ) {
1915
2162
  val fragmentSize = 4096
1916
2163
  imageSessionCounter++
@@ -1919,15 +2166,9 @@ class G2 : SGCManager() {
1919
2166
  var fragmentIndex = 0
1920
2167
  var offset = 0
1921
2168
 
1922
- fun sendNextFragment() {
1923
- if (offset >= bmpData.size) {
1924
- Bridge.log(
1925
- "G2: sendImageData($containerName) - $fragmentIndex fragments, ${bmpData.size} bytes"
1926
- )
1927
- onComplete?.invoke()
1928
- return
1929
- }
2169
+ Bridge.log("G2: sendImageData($containerName) - $fragmentIndex fragments, ${bmpData.size} bytes")
1930
2170
 
2171
+ while (offset < bmpData.size) {
1931
2172
  val end = minOf(offset + fragmentSize, bmpData.size)
1932
2173
  val fragment = bmpData.copyOfRange(offset, end)
1933
2174
 
@@ -1947,110 +2188,25 @@ class G2 : SGCManager() {
1947
2188
 
1948
2189
  fragmentIndex++
1949
2190
  offset = end
1950
-
1951
- // 200ms between fragments — and also before onComplete, so the next tile in
1952
- // sendImageDataChained gets the same gap before its first fragment (matches iOS,
1953
- // which awaits 200ms after every fragment including the last).
1954
- if (offset < bmpData.size) {
1955
- mainHandler.postDelayed({ sendNextFragment() }, 200)
1956
- } else {
1957
- Bridge.log(
1958
- "G2: sendImageData($containerName) - $fragmentIndex fragments, ${bmpData.size} bytes"
1959
- )
1960
- mainHandler.postDelayed({ onComplete?.invoke() }, 200)
1961
- }
2191
+ delay(200) // 200ms between fragments (and after the last, matching iOS)
1962
2192
  }
1963
-
1964
- sendNextFragment()
1965
- }
1966
-
1967
- /**
1968
- * Render any image to 400x200 grayscale, then slice into 4 tiles (200x100 each).
1969
- * Returns 4 BMP ByteArrays: [top-left, top-right, bottom-left, bottom-right].
1970
- * Mirrors G2.swift renderAndSliceTo4Tiles.
1971
- */
1972
- private fun renderAndSliceTo4Tiles(data: ByteArray): List<ByteArray>? {
1973
- val srcBitmap =
1974
- BitmapFactory.decodeByteArray(data, 0, data.size)
1975
- ?: run {
1976
- Bridge.log("G2: renderAndSliceTo4Tiles - could not decode image")
1977
- return null
1978
- }
1979
-
1980
- val srcWidth = srcBitmap.width
1981
- val srcHeight = srcBitmap.height
1982
- val tileWidth = 200
1983
- val tileHeight = 100
1984
- val totalW = tileWidth * 2 // 400
1985
- val totalH = tileHeight * 2 // 200
1986
-
1987
- // Scale source to fit within 400x200 (maintain aspect ratio)
1988
- val scale = minOf(totalW.toDouble() / srcWidth, totalH.toDouble() / srcHeight)
1989
- val scaledW = maxOf(1, (srcWidth * scale).toInt())
1990
- val scaledH = maxOf(1, (srcHeight * scale).toInt())
1991
- val offsetX = (totalW - scaledW) / 2
1992
- val offsetY = (totalH - scaledH) / 2
1993
-
1994
- Bridge.log(
1995
- "G2: renderAndSliceTo4Tiles - input ${srcWidth}x${srcHeight} → ${scaledW}x${scaledH} in ${totalW}x${totalH}"
1996
- )
1997
-
1998
- // Render to 400x200 with black background
1999
- val destBitmap = Bitmap.createBitmap(totalW, totalH, Bitmap.Config.ARGB_8888)
2000
- val canvas = Canvas(destBitmap)
2001
- canvas.drawColor(Color.BLACK)
2002
- val srcRect = Rect(0, 0, srcWidth, srcHeight)
2003
- val dstRect = Rect(offsetX, offsetY, offsetX + scaledW, offsetY + scaledH)
2004
- val paint = Paint(Paint.FILTER_BITMAP_FLAG)
2005
- canvas.drawBitmap(srcBitmap, srcRect, dstRect, paint)
2006
- srcBitmap.recycle()
2007
-
2008
- // Pull a single 400x200 grayscale buffer once; slice each tile from it
2009
- val fullPixels = ByteArray(totalW * totalH)
2010
- val argbRow = IntArray(totalW)
2011
- for (y in 0 until totalH) {
2012
- destBitmap.getPixels(argbRow, 0, totalW, 0, y, totalW, 1)
2013
- val rowOffset = y * totalW
2014
- for (x in 0 until totalW) {
2015
- val pixel = argbRow[x]
2016
- val r = (pixel shr 16) and 0xFF
2017
- val g = (pixel shr 8) and 0xFF
2018
- val b = pixel and 0xFF
2019
- fullPixels[rowOffset + x] = ((r * 299 + g * 587 + b * 114) / 1000).toByte()
2020
- }
2021
- }
2022
- destBitmap.recycle()
2023
-
2024
- // Slice into 4 tiles: top-left, top-right, bottom-left, bottom-right (matches iOS order)
2025
- val tileOrigins = listOf(0 to 0, tileWidth to 0, 0 to tileHeight, tileWidth to tileHeight)
2026
- val tiles = mutableListOf<ByteArray>()
2027
- for ((ox, oy) in tileOrigins) {
2028
- val tilePixels = ByteArray(tileWidth * tileHeight)
2029
- for (row in 0 until tileHeight) {
2030
- val srcRowStart = (oy + row) * totalW + ox
2031
- System.arraycopy(
2032
- fullPixels,
2033
- srcRowStart,
2034
- tilePixels,
2035
- row * tileWidth,
2036
- tileWidth
2037
- )
2038
- }
2039
- val bmp =
2040
- build4BitBmp(tilePixels, tileWidth, tileHeight)
2041
- ?: run {
2042
- Bridge.log(
2043
- "G2: renderAndSliceTo4Tiles - failed to build BMP for tile"
2044
- )
2045
- return null
2046
- }
2047
- tiles.add(bmp)
2048
- }
2049
- return tiles
2050
2193
  }
2051
2194
 
2195
+ /// Bring the Even Realities dashboard (the OS-level home/idle screen) to
2196
+ /// the foreground by tearing down whatever EvenHub page we currently own.
2197
+ /// The glasses fall back to the dashboard automatically when no page is up.
2052
2198
  override fun showDashboard() {
2053
- // G2 doesn't have a native dashboard concept via EvenHub
2199
+ Bridge.log("G2: showDashboard()")
2200
+ dashboardShowing += 2
2201
+ val msg = EvenHubProto.shutdownMessage()
2202
+ sendEvenHubCommand(msg)
2203
+ pageCreated = false
2204
+ currentBitmapBase64 = ""
2205
+ mainHandler.postDelayed({
2206
+ // activate the dashboard by setting depth to the current setting:
2207
+ val currentDepth = DeviceStore.get("bluetooth", "dashboard_depth") as? Int ?: 0
2208
+ setDashboardDepthOnly(currentDepth)
2209
+ }, 500)
2054
2210
  }
2055
2211
 
2056
2212
  override fun setDashboardPosition(height: Int, depth: Int) {
@@ -2091,6 +2247,93 @@ class G2 : SGCManager() {
2091
2247
  sendMenuCommand(msg)
2092
2248
  }
2093
2249
 
2250
+ /**
2251
+ * Bridge entry for `calendar_events` store updates. Each map is expected to match
2252
+ * the TS `CalendarEvent` shape: { title, location?, time, endDate } where `endDate`
2253
+ * is Unix seconds.
2254
+ *
2255
+ * Sends one BLE push per event, with `scheduleTotal` set to the batch size and
2256
+ * `scheduleNum` set to this event's 0-based slot. The widget pages through them on
2257
+ * the glasses — without paging info the firmware overwrites slot 0 on each push and
2258
+ * only the last event survives.
2259
+ */
2260
+ override fun sendCalendarEvents(events: List<Map<String, Any>>) {
2261
+ Bridge.log("G2: sendCalendarEvents -- ${events.size} events")
2262
+ if (events.isEmpty()) {
2263
+ sendDashboardCommand(
2264
+ CalendarProto.calendarClear(
2265
+ magicRandom = sendManager.nextMagicRandom(),
2266
+ packageId = 1,
2267
+ scheduleAuthority = 1
2268
+ )
2269
+ )
2270
+ return
2271
+ }
2272
+
2273
+ val total = events.size
2274
+ // Fold the local TZ offset into the timestamp so the glasses (which treat
2275
+ // timestamps as already-local) display the correct time — same hack as time-sync.
2276
+ val tzSec = TimeZone.getDefault().getOffset(System.currentTimeMillis()) / 1000
2277
+ events.forEachIndexed { i, ev ->
2278
+ val title = ev["title"] as? String ?: return@forEachIndexed
2279
+ val time = ev["time"] as? String ?: return@forEachIndexed
2280
+ val endTs = (ev["endDate"] as? Number)?.toLong() ?: return@forEachIndexed
2281
+ val location = ev["location"] as? String
2282
+
2283
+ val payload =
2284
+ CalendarProto.calendarPush(
2285
+ magicRandom = sendManager.nextMagicRandom(),
2286
+ packageId = 1,
2287
+ scheduleId = i + 1,
2288
+ title = title,
2289
+ location = location,
2290
+ time = time,
2291
+ endTimestamp = (endTs + tzSec).toInt(),
2292
+ scheduleAuthority = 1,
2293
+ scheduleTotal = total,
2294
+ scheduleNum = i
2295
+ )
2296
+ sendDashboardCommand(payload)
2297
+ }
2298
+ }
2299
+
2300
+ /**
2301
+ * Open the on-glasses notification panel — same effect as the user saying
2302
+ * "Hey Even, show notifications". Replicates the official-app voice flow:
2303
+ * 1. CTRL{status=ENTER} — puts glasses in AI session
2304
+ * 2. ASK{text=" "} — minimal ASR transcript to seed session context
2305
+ * 3. SKILL{skillId=NOTIFICATION, skillParam=show, ...} — dispatches the intent
2306
+ * The SKILL step alone is ignored by firmware; the preceding ENTER+ASK
2307
+ * supply the session context that lets the glasses act on the SKILL.
2308
+ */
2309
+ override suspend fun showNotificationsPanel() {
2310
+ Bridge.log("G2: showNotificationsPanel()")
2311
+ val enterPayload = EvenAIProto.aiCtrl(
2312
+ magicRandom = sendManager.nextMagicRandom(),
2313
+ status = 2 // EVEN_AI_ENTER
2314
+ )
2315
+ sendEvenAICommand(enterPayload)
2316
+
2317
+ delay(400)
2318
+ val askPayload = EvenAIProto.aiAsk(
2319
+ magicRandom = sendManager.nextMagicRandom(),
2320
+ text = " ",
2321
+ streamEnable = 0
2322
+ )
2323
+ sendEvenAICommand(askPayload)
2324
+
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)
2335
+ }
2336
+
2094
2337
  override fun setBrightness(level: Int, autoMode: Boolean) {
2095
2338
  Bridge.log("G2: setBrightness($level, auto=$autoMode)")
2096
2339
  val msg =
@@ -2104,60 +2347,81 @@ class G2 : SGCManager() {
2104
2347
 
2105
2348
  // ---------- Private Display Helpers ----------
2106
2349
 
2107
- private fun createPageWithText(text: String) {
2108
- val tc =
2109
- EvenHubProto.textContainerProperty(
2110
- x = 0,
2111
- y = 0,
2112
- width = 576,
2113
- height = 288,
2114
- borderWidth = 0,
2115
- borderColor = 0,
2116
- borderRadius = 0,
2117
- paddingLength = 4,
2118
- containerID = textContainerID,
2119
- containerName = "text-main",
2120
- isEventCapture = true,
2121
- 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
+ )
2122
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
+ }
2389
+
2390
+ // Build the page's image containers from the live tracked list.
2391
+ val imageContainerProps: List<ByteArray> =
2392
+ imageContainers.map { c ->
2393
+ EvenHubProto.imageContainerProperty(
2394
+ x = c.x,
2395
+ y = c.y,
2396
+ width = c.width,
2397
+ height = c.height,
2398
+ containerID = c.id,
2399
+ containerName = c.name
2400
+ )
2401
+ }
2123
2402
 
2124
2403
  val msg: ByteArray
2125
- if (!startupPageCreated) {
2126
- Bridge.log("G2: createPageWithText - using createPageMessage (first time)")
2404
+ if (!pageCreated) {
2405
+ Bridge.log("G2: using createPageMessage (first time)")
2127
2406
  msg =
2128
2407
  EvenHubProto.createPageMessage(
2129
- textContainers = listOf(tc),
2408
+ textContainers = textContainerProps,
2409
+ imageContainers = imageContainerProps,
2130
2410
  magicRandom = sendManager.nextMagicRandom(),
2131
2411
  appId = activeMenuAppId
2132
2412
  )
2133
- startupPageCreated = true
2134
2413
  } else {
2135
- Bridge.log("G2: createPageWithText - using rebuildPageMessage")
2414
+ Bridge.log("G2: using rebuildPageMessage")
2136
2415
  msg =
2137
2416
  EvenHubProto.rebuildPageMessage(
2138
- textContainers = listOf(tc),
2417
+ textContainers = textContainerProps,
2418
+ imageContainers = imageContainerProps,
2139
2419
  magicRandom = sendManager.nextMagicRandom(),
2140
2420
  appId = activeMenuAppId
2141
2421
  )
2142
2422
  }
2143
2423
  sendEvenHubCommand(msg)
2144
2424
  pageCreated = true
2145
- pageHasTextContainer = true
2146
- currentTextContent = text
2147
- currentBitmapBase64 = ""
2148
- }
2149
-
2150
- private fun updateText(text: String) {
2151
- val msg =
2152
- EvenHubProto.updateTextMessage(
2153
- containerID = textContainerID,
2154
- contentOffset = 0,
2155
- contentLength = text.toByteArray(Charsets.UTF_8).size,
2156
- content = text
2157
- )
2158
- queueEvenHubCommand(msg)
2159
- currentTextContent = text
2160
- currentBitmapBase64 = ""
2161
2425
  }
2162
2426
 
2163
2427
  @Synchronized
@@ -2307,9 +2571,9 @@ class G2 : SGCManager() {
2307
2571
  bmp.write(rowBuf)
2308
2572
  }
2309
2573
 
2310
- Bridge.log(
2311
- "G2: build4BitBmp - ${bmp.size()} bytes (header=$headerSize, pixels=$pixelDataSize, rows=${paddedRowSize}x$height)"
2312
- )
2574
+ // Bridge.log(
2575
+ // "G2: build4BitBmp - ${bmp.size()} bytes (header=$headerSize, pixels=$pixelDataSize, rows=${paddedRowSize}x$height)"
2576
+ // )
2313
2577
  return bmp.toByteArray()
2314
2578
  }
2315
2579
 
@@ -2327,21 +2591,40 @@ class G2 : SGCManager() {
2327
2591
 
2328
2592
  // ---------- SGCManager: Audio Control ----------
2329
2593
 
2330
- override fun setMicEnabled(enabled: Boolean) {
2331
- Bridge.log("G2: setMicEnabled($enabled)")
2594
+ private fun restartMicIfAlreadyEnabled() {
2332
2595
  val currentEnabled = DeviceStore.get("glasses", "micEnabled") as? Boolean ?: false
2596
+ if (currentEnabled) {
2597
+ restartMic()
2598
+ }
2599
+ }
2333
2600
 
2601
+ fun restartMic() {
2334
2602
  // if already enabled, set to disabled, then send enabled after 500ms:
2335
- if (currentEnabled && enabled) {
2336
- DeviceStore.apply("glasses", "micEnabled", true)
2337
- val msg = EvenHubProto.audioControlMessage(false)
2603
+ DeviceStore.apply("glasses", "micEnabled", true)
2604
+ val msg = EvenHubProto.audioControlMessage(false)
2605
+ sendEvenHubCommand(msg)
2606
+ mainHandler.postDelayed({
2607
+ val useNativeDashboard = DeviceStore.get("bluetooth", "use_native_dashboard") as? Boolean ?: false
2608
+ // Bridge.log("G2: setMicEnabled - useNativeDashboard=$useNativeDashboard, dashboardShowing=$dashboardShowing")
2609
+ if (useNativeDashboard && dashboardShowing > 0) {
2610
+ return@postDelayed
2611
+ }
2612
+ if (!pageCreated) {
2613
+ DeviceManager.getInstance().sendCurrentState() // should re-create the page if needed
2614
+ }
2615
+ val msg = EvenHubProto.audioControlMessage(true)
2338
2616
  sendEvenHubCommand(msg)
2339
- mainHandler.postDelayed({
2340
- val msg = EvenHubProto.audioControlMessage(true)
2341
- sendEvenHubCommand(msg)
2342
- }, 500)
2617
+ }, 500)
2618
+ }
2619
+
2620
+ override fun setMicEnabled(enabled: Boolean) {
2621
+ Bridge.log("G2: setMicEnabled($enabled)")
2622
+ val currentEnabled = DeviceStore.get("glasses", "micEnabled") as? Boolean ?: false
2623
+ if (currentEnabled && enabled) {
2624
+ restartMic()
2343
2625
  return
2344
2626
  }
2627
+
2345
2628
  DeviceStore.apply("glasses", "micEnabled", enabled)
2346
2629
  val msg = EvenHubProto.audioControlMessage(enabled)
2347
2630
  sendEvenHubCommand(msg)
@@ -2360,6 +2643,7 @@ class G2 : SGCManager() {
2360
2643
  authToken: String?,
2361
2644
  compress: String?,
2362
2645
  flash: Boolean,
2646
+ save: Boolean,
2363
2647
  sound: Boolean,
2364
2648
  exposureTimeNs: Long?,
2365
2649
  iso: Int?,
@@ -2463,7 +2747,9 @@ class G2 : SGCManager() {
2463
2747
  rightAuthenticated = false
2464
2748
  startupPageCreated = false
2465
2749
  pageCreated = false
2466
- pageHasTextContainer = false
2750
+ imageContainers.clear()
2751
+ textContainers.clear()
2752
+ dashboardShowing = 0
2467
2753
  heartbeatCounter = 0
2468
2754
  currentBitmapBase64 = ""
2469
2755
  menuAppIdToPackageName.clear()
@@ -2506,10 +2792,78 @@ class G2 : SGCManager() {
2506
2792
  }
2507
2793
 
2508
2794
  override fun dbg1() {
2509
- connectController()
2795
+ // showNotificationsPanel()
2510
2796
  }
2797
+
2798
+ private var compassRunning = false
2799
+
2511
2800
  override fun dbg2() {
2512
- 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
+ }
2513
2867
  }
2514
2868
 
2515
2869
  fun reconnectController() {
@@ -2598,7 +2952,12 @@ class G2 : SGCManager() {
2598
2952
  }
2599
2953
 
2600
2954
  override fun sendShutdown() {
2601
- 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
2602
2961
  disconnect()
2603
2962
  }
2604
2963
 
@@ -2606,6 +2965,14 @@ class G2 : SGCManager() {
2606
2965
  // TODO: Implement via dev_settings
2607
2966
  }
2608
2967
 
2968
+ /// Push the current time to the glasses. Useful after DST transitions,
2969
+ /// time-zone travel, or a long sleep where the glasses' clock has drifted.
2970
+ fun syncTime() {
2971
+ Bridge.log("G2: syncTime()")
2972
+ val msg = DevSettingsProto.timeSync(sendManager.nextMagicRandom())
2973
+ sendDevSettingsCommand(msg, left = true, right = true)
2974
+ }
2975
+
2609
2976
  override fun sendRgbLedControl(
2610
2977
  requestId: String,
2611
2978
  packageName: String?,
@@ -2939,7 +3306,7 @@ class G2 : SGCManager() {
2939
3306
 
2940
3307
  startupPageCreated = false
2941
3308
  pageCreated = false
2942
- pageHasTextContainer = false
3309
+ dashboardShowing = 0
2943
3310
  DeviceStore.apply("glasses", "connected", false)
2944
3311
  DeviceStore.apply("glasses", "fullyBooted", false)
2945
3312
 
@@ -3025,7 +3392,7 @@ class G2 : SGCManager() {
3025
3392
  stopScan()
3026
3393
  authStarted = true
3027
3394
  Bridge.log("G2: Both sides initialized, starting auth sequence")
3028
- runAuthSequence()
3395
+ displayScope.launch { runAuthSequence() }
3029
3396
  }
3030
3397
  }
3031
3398
  }
@@ -3105,6 +3472,8 @@ class G2 : SGCManager() {
3105
3472
  ServiceID.MENU.value -> handleMenuResponse(payload)
3106
3473
  ServiceID.DASHBOARD.value -> handleDashboardResponse(payload)
3107
3474
  ServiceID.GESTURE_CTRL.value -> handleGestureCtrl(payload)
3475
+ ServiceID.NAVIGATION.value -> handleNavigationResponse(payload)
3476
+ ServiceID.EVEN_AI.value -> handleEvenAIResponse(payload)
3108
3477
  ServiceID.EVEN_HUB_CTRL.value -> handleEvenHubCtrlResponse(payload)
3109
3478
  else -> {
3110
3479
  Bridge.log(
@@ -3116,6 +3485,69 @@ class G2 : SGCManager() {
3116
3485
  }
3117
3486
  }
3118
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
+
3119
3551
  private fun handleEvenHubResponse(payload: ByteArray) {
3120
3552
  val reader = ProtobufReader(payload)
3121
3553
  val fields = reader.parseFields()
@@ -3166,7 +3598,22 @@ class G2 : SGCManager() {
3166
3598
  Bridge.log("G2: Menu selection ignored — placeholder or unknown appId=$appId")
3167
3599
  }
3168
3600
  } else {
3169
- // 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.
3170
3617
  // field 4 = StartupResCmd, field 6 = ImgResCmd, field 8 = RebuildResCmd, field 10 =
3171
3618
  // TextResCmd
3172
3619
  for (resField in listOf(4, 6, 8, 10)) {
@@ -3180,26 +3627,24 @@ class G2 : SGCManager() {
3180
3627
  Bridge.log(
3181
3628
  "G2: WARN: Glasses shutdown our EvenHub page — resetting page state"
3182
3629
  )
3183
- startupPageCreated = false
3184
3630
  pageCreated = false
3185
- pageHasTextContainer = false
3186
- currentTextContent = ""
3187
3631
  }
3188
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()
3189
3639
  (resFields[8] as? Int)?.let { errorCode ->
3190
3640
  // ImgResCmd has ErrorCode in field 8
3191
- Bridge.log("G2: EvenHub ImgRes errorCode=$errorCode")
3641
+ if (errorCode == 4) {
3642
+ Bridge.log("G2: img_success")
3643
+ } else {
3644
+ Bridge.log("G2: EvenHub ImgRes errorCode=$errorCode")
3645
+ }
3192
3646
  }
3193
3647
  }
3194
-
3195
- // If glasses sent a shutdown (cmd=9/10), our page is gone — reset state
3196
- if (cmdValue == 9 || cmdValue == 10) {
3197
- Bridge.log("G2: ERROR: Glasses shutdown our EvenHub page — resetting page state")
3198
- startupPageCreated = false
3199
- pageCreated = false
3200
- pageHasTextContainer = false
3201
- currentTextContent = ""
3202
- }
3203
3648
  }
3204
3649
  }
3205
3650
 
@@ -3227,6 +3672,39 @@ class G2 : SGCManager() {
3227
3672
  }
3228
3673
  }
3229
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
+
3230
3708
  private fun handleTouchEvent(devEventData: ByteArray) {
3231
3709
  // Parse SendDeviceEvent: field 1=ListEvent, field 2=TextEvent, field 3=SysEvent
3232
3710
  val reader = ProtobufReader(devEventData)
@@ -3242,6 +3720,19 @@ class G2 : SGCManager() {
3242
3720
  val sysReader = ProtobufReader(sysData)
3243
3721
  val sysFields = sysReader.parseFields()
3244
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
+
3245
3736
  val normalType = sysFields[1] as? Int
3246
3737
  val eventType: OsEventType? =
3247
3738
  if (normalType != null) OsEventType.fromInt(normalType) else OsEventType.CLICK
@@ -3269,22 +3760,20 @@ class G2 : SGCManager() {
3269
3760
  if (eventType == OsEventType.DOUBLE_CLICK) {
3270
3761
  // trigger dashboard:
3271
3762
  val isHeadUp = DeviceStore.get("glasses", "headUp") as? Boolean ?: false
3272
- // toggle head up:
3273
- DeviceStore.apply("glasses", "headUp", !isHeadUp)
3274
- // if (isHeadUp) {
3275
- // // clear the display after a delay:
3276
- // mainHandler.postDelayed({ clearDisplay() }, 500)
3277
- // }
3763
+
3764
+ val useNativeDashboard = DeviceStore.get("bluetooth", "use_native_dashboard") as? Boolean ?: false
3765
+ if (useNativeDashboard) {
3766
+ showDashboard()
3767
+ } else {
3768
+ // toggle head up:
3769
+ DeviceStore.apply("glasses", "headUp", !isHeadUp)
3770
+ }
3278
3771
  }
3279
3772
 
3280
3773
  // System exit: glasses killed our EvenHub page (user opened menu or another app)
3281
3774
  // Reset page state and re-create the page to reclaim EvenHub focus
3282
3775
  if (eventType == OsEventType.SYSTEM_EXIT || eventType == OsEventType.ABNORMAL_EXIT) {
3283
- startupPageCreated = false
3284
3776
  pageCreated = false
3285
- pageHasTextContainer = false
3286
- currentTextContent = ""
3287
- currentBitmapBase64 = ""
3288
3777
  // Firmware kills the mic on system exit; re-arm it if it should be on
3289
3778
  DeviceStore.apply("glasses", "micEnabled", false)
3290
3779
  DeviceManager.getInstance().updateMicState()
@@ -3325,7 +3814,8 @@ class G2 : SGCManager() {
3325
3814
  OsEventType.FOREGROUND_ENTER -> "foreground_enter"
3326
3815
  OsEventType.FOREGROUND_EXIT -> "foreground_exit"
3327
3816
  OsEventType.SYSTEM_EXIT -> "system_exit"
3328
- OsEventType.ABNORMAL_EXIT -> null
3817
+ OsEventType.IMU_DATA_REPORT -> null
3818
+ OsEventType.ABNORMAL_EXIT -> null // don't report abnormal exits as gestures
3329
3819
  }
3330
3820
  }
3331
3821
 
@@ -3337,9 +3827,9 @@ class G2 : SGCManager() {
3337
3827
  // Ignore heartbeat acks
3338
3828
  if (cmdValue == DevCfgCommandId.BASE_CONN_HEART_BEAT.value) return
3339
3829
 
3340
- Bridge.log(
3341
- "G2: DevSettings response: ${payload.take(32).joinToString(":") { String.format("%02X", it) }}"
3342
- )
3830
+ // Bridge.log(
3831
+ // "G2: DevSettings response: ${payload.take(32).joinToString(":") { String.format("%02X", it) }}"
3832
+ // )
3343
3833
 
3344
3834
  if (cmdValue == DevCfgCommandId.AUTHENTICATION.value) {
3345
3835
  // DevCfgDataPackage: field 2 = magicRandom, field 3 = AuthMgr { field 1 = secAuth }
@@ -3381,7 +3871,7 @@ class G2 : SGCManager() {
3381
3871
  }
3382
3872
 
3383
3873
  val connStatus = ringFields[4] as? Int ?: -1
3384
- Bridge.log("G2: Ring connection status: connStatus?=$connStatus")
3874
+ // Bridge.log("G2: Ring connection status: connStatus?=$connStatus")
3385
3875
 
3386
3876
  if (connStatus == 22) {
3387
3877
  Bridge.log("G2: Ring disconnected")
@@ -3445,14 +3935,59 @@ class G2 : SGCManager() {
3445
3935
  }
3446
3936
 
3447
3937
  private fun handleGestureCtrl(payload: ByteArray) {
3938
+ // Dedup: L and R peripherals both deliver this event, so debounce within 500ms.
3939
+ val timestamp = System.currentTimeMillis()
3940
+ val last = lastGestureCtrlTimestamp
3941
+ if (last != null && timestamp - last < 500) {
3942
+ // Bridge.log("G2: gesture_ctrl dedup")
3943
+ return
3944
+ }
3945
+ lastGestureCtrlTimestamp = timestamp
3946
+
3448
3947
  // Dashboard close detection: 08011A00 means dashboard closed
3449
3948
  if (payload.contentEquals(byteArrayOf(0x08, 0x01, 0x1A, 0x00))) {
3450
- Bridge.log("G2: gesture_ctrl response: dashboard closed")
3451
- // Re-send mic on / update mic state
3452
- DeviceStore.apply("glasses", "micEnabled", false)
3453
- DeviceManager.getInstance().updateMicState()
3454
- // Reset the text container
3455
- sendTextWall(" ")
3949
+ Bridge.log("G2: dashboard closed / shutdown - dashboardShowing=$dashboardShowing")
3950
+ val useNativeDashboard = DeviceStore.get("bluetooth", "use_native_dashboard") as? Boolean ?: false
3951
+ if (!useNativeDashboard) {
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
+ }
3965
+ }
3966
+ return
3967
+ } else {
3968
+ // if we aren't trying to show the dashboard
3969
+ // then we need to turn the mic back on and display the mentra main page:
3970
+ if (dashboardShowing <= 1) {
3971
+ dashboardShowing = 0
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
+ }
3982
+ }
3983
+ return
3984
+ }
3985
+ // do nothing this time since we just closed the dashboard
3986
+ dashboardShowing -= 1
3987
+ if (dashboardShowing < 0) {
3988
+ dashboardShowing = 0
3989
+ }
3990
+ }
3456
3991
  }
3457
3992
  }
3458
3993
 
@@ -3479,7 +4014,7 @@ class G2 : SGCManager() {
3479
4014
  // Battery
3480
4015
  (fields[12] as? Int)?.let { battery ->
3481
4016
  if (battery in 0..100) {
3482
- Bridge.log("G2: Battery level: $battery%")
4017
+ // Bridge.log("G2: Battery level: $battery%")
3483
4018
  batteryLevel_ = battery
3484
4019
  }
3485
4020
  }
@@ -3496,13 +4031,13 @@ class G2 : SGCManager() {
3496
4031
  // Software versions
3497
4032
  (fields[5] as? ByteArray)?.let { leftVer ->
3498
4033
  val leftVersion = String(leftVer, Charsets.UTF_8)
3499
- Bridge.log("G2: Left firmware: $leftVersion")
4034
+ // Bridge.log("G2: Left firmware: $leftVersion")
3500
4035
  DeviceStore.apply("glasses", "leftFirmwareVersion", leftVersion)
3501
4036
  }
3502
4037
 
3503
4038
  (fields[6] as? ByteArray)?.let { rightVer ->
3504
4039
  val rightVersion = String(rightVer, Charsets.UTF_8)
3505
- Bridge.log("G2: Right firmware: $rightVersion")
4040
+ // Bridge.log("G2: Right firmware: $rightVersion")
3506
4041
  DeviceStore.apply("glasses", "rightFirmwareVersion", rightVersion)
3507
4042
  DeviceStore.apply("glasses", "firmwareVersion", rightVersion)
3508
4043
  }
@@ -3531,7 +4066,7 @@ class G2 : SGCManager() {
3531
4066
  return
3532
4067
  }
3533
4068
  lastAudioFrame = audioData
3534
- 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) }}")
3535
4070
  DeviceManager.getInstance().handleGlassesMicData(audioData, 40)
3536
4071
  }
3537
4072