@mentra/bluetooth-sdk 0.1.8 → 0.1.10

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 +165 -27
  23. package/android/src/main/java/com/mentra/bluetoothsdk/Bridge.kt +89 -21
  24. package/android/src/main/java/com/mentra/bluetoothsdk/DeviceManager.kt +189 -16
  25. package/android/src/main/java/com/mentra/bluetoothsdk/DeviceStore.kt +22 -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 +123 -10
  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 +569 -220
  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 +176 -104
  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 +54 -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 +241 -43
  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 +36 -23
  68. package/build/_private/BluetoothSdkModule.d.ts.map +1 -1
  69. package/build/_private/BluetoothSdkModule.js +30 -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 +23 -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 +174 -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 +58 -19
  98. package/ios/Source/DeviceManager.swift +149 -17
  99. package/ios/Source/DeviceStore.swift +16 -6
  100. package/ios/Source/MentraBluetoothSDK.swift +803 -47
  101. package/ios/Source/ObservableStore.swift +8 -2
  102. package/ios/Source/{Audio → audio}/AudioModels.swift +9 -7
  103. package/ios/Source/{Camera → camera}/CameraModels.swift +225 -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 +2 -2
  111. package/ios/Source/sgcs/G2.swift +645 -403
  112. package/ios/Source/sgcs/Mach1.swift +2 -2
  113. package/ios/Source/sgcs/MentraLive.swift +812 -319
  114. package/ios/Source/sgcs/MentraNex.swift +412 -182
  115. package/ios/Source/sgcs/SGCManager.swift +57 -3
  116. package/ios/Source/sgcs/Simulated.swift +7 -3
  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 +331 -59
  131. package/src/_private/BluetoothSdkModule.ts +113 -102
  132. package/src/_private/photoRequestPayload.ts +28 -0
  133. package/src/index.ts +58 -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
@@ -496,16 +496,20 @@ private object DevSettingsProto {
496
496
  return w.toByteArray()
497
497
  }
498
498
 
499
+ /// DevCfgDataPackage with TIME_SYNC command.
500
+ /// TimeSync submessage: f1 = (Unix seconds + TZ offset seconds) as Int32, no TZ field.
501
+ /// Firmware appears to ignore the TZ field, so we pre-shift the timestamp itself
502
+ /// to make UTC interpretation read as local. Empirically confirmed via probe variants in dbg1().
499
503
  fun timeSync(magicRandom: Int): ByteArray {
500
504
  val w = ProtobufWriter()
501
505
  w.writeInt32Field(1, DevCfgCommandId.TIME_SYNC.value)
502
506
  w.writeInt32Field(2, magicRandom)
503
507
 
504
508
  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)
509
+ val nowMs = System.currentTimeMillis()
510
+ val nowSec = nowMs / 1000
511
+ val tzSec = (TimeZone.getDefault().getOffset(nowMs) / 1000).toLong()
512
+ tsW.writeInt32Field(1, (nowSec + tzSec).toInt())
509
513
  w.writeMessageField(128, tsW.toByteArray())
510
514
  return w.toByteArray()
511
515
  }
@@ -676,6 +680,69 @@ private object EvenAIProto {
676
680
  w.writeMessageField(13, configW.toByteArray()) // config (field 13)
677
681
  return w.toByteArray()
678
682
  }
683
+
684
+ /**
685
+ * EvenAIDataPackage with CTRL — used to put glasses into / out of an AI session.
686
+ * Mirrors Flutter `sendWakeupResp` which sends EvenAIControl{status=ENTER}.
687
+ * status: 1 WAKE_UP, 2 ENTER, 3 EXIT
688
+ */
689
+ fun aiCtrl(magicRandom: Int, status: Int): ByteArray {
690
+ val ctrlW = ProtobufWriter()
691
+ ctrlW.writeInt32Field(1, status) // status
692
+
693
+ val w = ProtobufWriter()
694
+ w.writeInt32Field(1, 1) // commandId = CTRL
695
+ w.writeInt32Field(2, magicRandom)
696
+ w.writeMessageField(3, ctrlW.toByteArray()) // ctrl (field 3)
697
+ return w.toByteArray()
698
+ }
699
+
700
+ /**
701
+ * EvenAIDataPackage with ASK — what the phone sends after cloud ASR resolves the
702
+ * user's audio into text. Mirrors Flutter `sendAsr`: EvenAIAskInfo{text, streamEnable=0}.
703
+ */
704
+ fun aiAsk(magicRandom: Int, text: String, streamEnable: Int = 0): ByteArray {
705
+ val askW = ProtobufWriter()
706
+ askW.writeInt32Field(2, streamEnable) // streamEnable
707
+ askW.writeBytesField(4, text.toByteArray(Charsets.UTF_8)) // text
708
+
709
+ val w = ProtobufWriter()
710
+ w.writeInt32Field(1, 3) // commandId = ASK
711
+ w.writeInt32Field(2, magicRandom)
712
+ w.writeMessageField(5, askW.toByteArray()) // askInfo (field 5)
713
+ return w.toByteArray()
714
+ }
715
+
716
+ /**
717
+ * EvenAIDataPackage with SKILL — triggers a built-in glasses UI the same way
718
+ * "Hey Even, show X" voice command does.
719
+ * skillId values (per even_ai.proto):
720
+ * 0 SKILL_NONE, 1 BRIGHTNESS, 2 TRANSLATE_CTRL, 3 NOTIFICATION,
721
+ * 4 TELEPROMPT, 5 NAVIGATE, 6 CONVERSATE, 7 QUICKLIST, 8 AUTO_BRIGHTNESS
722
+ */
723
+ fun triggerSkill(
724
+ magicRandom: Int,
725
+ skillId: Int,
726
+ skillParam: Int = 0,
727
+ text: String = "",
728
+ streamEnable: Int = 1,
729
+ fTextEnd: Int = 1
730
+ ): ByteArray {
731
+ // EvenAISkillInfo
732
+ val skillW = ProtobufWriter()
733
+ skillW.writeInt32Field(1, streamEnable) // streamEnable
734
+ skillW.writeInt32Field(2, skillId) // skillId
735
+ skillW.writeInt32Field(3, skillParam) // skillParam — for NOTIFICATION skill this is a NotificationType enum
736
+ skillW.writeBytesField(4, text.toByteArray(Charsets.UTF_8)) // text
737
+ skillW.writeInt32Field(6, fTextEnd) // fTextEnd — 1 signals "this is the final/complete packet"
738
+
739
+ // EvenAIDataPackage
740
+ val w = ProtobufWriter()
741
+ w.writeInt32Field(1, 6) // commandId = SKILL
742
+ w.writeInt32Field(2, magicRandom)
743
+ w.writeMessageField(8, skillW.toByteArray()) // skillInfo (field 8)
744
+ return w.toByteArray()
745
+ }
679
746
  }
680
747
 
681
748
  // ---------- Menu Protobuf Builders (menu.proto, service ID 3) ----------
@@ -757,6 +824,124 @@ private object MenuProto {
757
824
  }
758
825
  }
759
826
 
827
+ /**
828
+ * Builders for the dashboard calendar widget (service 0x01, command Dashboard_Receive = 2).
829
+ * Field numbers come from the extracted dashboard.proto v2.1.0_beta_v3 and mirror the iOS
830
+ * DashboardProto.calendarPush builder so both platforms produce the same wire payload.
831
+ */
832
+ private object CalendarProto {
833
+ // Dashboard_Receive — phone → glasses widget/config push.
834
+ const val DASHBOARD_RECEIVE = 2
835
+
836
+ /**
837
+ * Build a Schedule submessage (the calendar event payload).
838
+ * f1 = scheduleId (int32, required)
839
+ * f2 = title (string, optional)
840
+ * f3 = location (string, optional)
841
+ * f4 = time (string, optional — display text e.g. "10:00 AM")
842
+ * f5 = endTimestamp (int32, Unix seconds — pre-shifted by TZ so the glasses,
843
+ * which treat timestamps as already-local, display local time)
844
+ */
845
+ private fun schedule(
846
+ scheduleId: Int,
847
+ title: String?,
848
+ location: String?,
849
+ time: String?,
850
+ endTimestamp: Int
851
+ ): ByteArray {
852
+ val w = ProtobufWriter()
853
+ w.writeInt32Field(1, scheduleId)
854
+ title?.let { w.writeStringField(2, it) }
855
+ location?.let { w.writeStringField(3, it) }
856
+ time?.let { w.writeStringField(4, it) }
857
+ w.writeInt32Field(5, endTimestamp)
858
+ return w.toByteArray()
859
+ }
860
+
861
+ /**
862
+ * Build the full calendar-push DashboardDataPackage:
863
+ * DashboardDataPackage {
864
+ * commandId = Dashboard_Receive (2)
865
+ * magicRandom
866
+ * dashboardReceive = DashboardReceiveFromApp {
867
+ * packageId = 1
868
+ * bashboardConfig = DashboardContent {
869
+ * widgetComponents = rWidgetComponent {
870
+ * schedule = rScheduleWidget {
871
+ * scheduleTotal, scheduleNum (0-based), Schedule, scheduleAuthority
872
+ * }
873
+ * }
874
+ * }
875
+ * }
876
+ * }
877
+ */
878
+ fun calendarPush(
879
+ magicRandom: Int,
880
+ packageId: Int,
881
+ scheduleId: Int,
882
+ title: String?,
883
+ location: String?,
884
+ time: String?,
885
+ endTimestamp: Int,
886
+ scheduleAuthority: Int,
887
+ scheduleTotal: Int,
888
+ scheduleNum: Int
889
+ ): ByteArray {
890
+ val sched = schedule(scheduleId, title, location, time, endTimestamp)
891
+
892
+ // rScheduleWidget { f1 = scheduleTotal, f2 = scheduleNum, f3 = Schedule, f4 = scheduleAuthority }
893
+ val rSchedW = ProtobufWriter()
894
+ rSchedW.writeInt32Field(1, scheduleTotal)
895
+ rSchedW.writeInt32Field(2, scheduleNum)
896
+ rSchedW.writeMessageField(3, sched)
897
+ rSchedW.writeInt32Field(4, scheduleAuthority)
898
+
899
+ // rWidgetComponent { f3 = rScheduleWidget }
900
+ val rWidgetW = ProtobufWriter()
901
+ rWidgetW.writeMessageField(3, rSchedW.toByteArray())
902
+
903
+ // DashboardContent { f2 = rWidgetComponent }
904
+ val contentW = ProtobufWriter()
905
+ contentW.writeMessageField(2, rWidgetW.toByteArray())
906
+
907
+ // DashboardReceiveFromApp { f1 = packageId, f3 = DashboardContent }
908
+ val receiveW = ProtobufWriter()
909
+ receiveW.writeInt32Field(1, packageId)
910
+ receiveW.writeMessageField(3, contentW.toByteArray())
911
+
912
+ // DashboardDataPackage { f1 = commandId, f2 = magicRandom, f4 = dashboardReceive }
913
+ val pkgW = ProtobufWriter()
914
+ pkgW.writeInt32Field(1, DASHBOARD_RECEIVE)
915
+ pkgW.writeInt32Field(2, magicRandom)
916
+ pkgW.writeMessageField(4, receiveW.toByteArray())
917
+ return pkgW.toByteArray()
918
+ }
919
+
920
+ fun calendarClear(magicRandom: Int, packageId: Int, scheduleAuthority: Int): ByteArray {
921
+ // rScheduleWidget with scheduleTotal=0 clears the widget without sending a stale Schedule.
922
+ val rSchedW = ProtobufWriter()
923
+ rSchedW.writeInt32Field(1, 0)
924
+ rSchedW.writeInt32Field(2, 0)
925
+ rSchedW.writeInt32Field(4, scheduleAuthority)
926
+
927
+ val rWidgetW = ProtobufWriter()
928
+ rWidgetW.writeMessageField(3, rSchedW.toByteArray())
929
+
930
+ val contentW = ProtobufWriter()
931
+ contentW.writeMessageField(2, rWidgetW.toByteArray())
932
+
933
+ val receiveW = ProtobufWriter()
934
+ receiveW.writeInt32Field(1, packageId)
935
+ receiveW.writeMessageField(3, contentW.toByteArray())
936
+
937
+ val pkgW = ProtobufWriter()
938
+ pkgW.writeInt32Field(1, DASHBOARD_RECEIVE)
939
+ pkgW.writeInt32Field(2, magicRandom)
940
+ pkgW.writeMessageField(4, receiveW.toByteArray())
941
+ return pkgW.toByteArray()
942
+ }
943
+ }
944
+
760
945
  // ---------- EvenBLE Transport Layer ----------
761
946
 
762
947
  private object EvenBLETransport {
@@ -1050,6 +1235,7 @@ class G2 : SGCManager() {
1050
1235
  private var leftAuthenticated: Boolean = false
1051
1236
  private var rightAuthenticated: Boolean = false
1052
1237
  private var currentBitmapBase64: String = ""
1238
+ private var dashboardShowing = 0
1053
1239
 
1054
1240
  // Dashboard menu state
1055
1241
  private var menuAppIdToPackageName: MutableMap<Int, String> = mutableMapOf()
@@ -1057,6 +1243,35 @@ class G2 : SGCManager() {
1057
1243
  private var activeMenuAppId: Int? = null
1058
1244
  private var lastClickTimestamp: Long? = null
1059
1245
  private var lastMenuSelectTimestamp: Long? = null
1246
+ private var lastGestureCtrlTimestamp: Long? = null
1247
+
1248
+ /** A tracked image container on the current page. Keyed by its rect for reuse. */
1249
+ private data class ImgContainer(
1250
+ val id: Int,
1251
+ val x: Int,
1252
+ val y: Int,
1253
+ val width: Int,
1254
+ val height: Int
1255
+ ) {
1256
+ val name: String
1257
+ get() = "img-$id"
1258
+
1259
+ fun matches(x: Int, y: Int, width: Int, height: Int): Boolean =
1260
+ this.x == x && this.y == y && this.width == width && this.height == height
1261
+ }
1262
+
1263
+ /**
1264
+ * Live list of image containers on the page, ordered oldest→newest (for LRU eviction). The page
1265
+ * may hold at most 4 image containers (IDs from the pool below).
1266
+ */
1267
+ private val imageContainers: MutableList<ImgContainer> = mutableListOf()
1268
+ /** Fixed pool of container IDs the page protocol expects. */
1269
+ private val imageContainerIDPool: List<Int> = listOf(10, 11, 12, 13)
1270
+ /** Default container seeded into every fresh page: 100x100 in the top-left. */
1271
+ private val defaultImgX = 0
1272
+ private val defaultImgY = 0
1273
+ private val defaultImgWidth = 100
1274
+ private val defaultImgHeight = 100
1060
1275
 
1061
1276
  // Battery state
1062
1277
  private var _batteryLevel: Int = -1
@@ -1137,7 +1352,7 @@ class G2 : SGCManager() {
1137
1352
  payload = payload,
1138
1353
  reserveFlag = true
1139
1354
  )
1140
- sendToGlasses(packets)
1355
+ sendToGlasses(packets, left = true, right = true)
1141
1356
  }
1142
1357
 
1143
1358
  private fun sendDevSettingsCommand(
@@ -1303,11 +1518,13 @@ class G2 : SGCManager() {
1303
1518
  0x10,
1304
1519
  0x00, // distanceUnit=0
1305
1520
  0x18,
1306
- 0x01, // timeFormat=1
1521
+ dashboardHalfDayFormat().toByte(),
1522
+ // timeFormat / halfDayFormat
1307
1523
  0x20,
1308
1524
  0x00, // dateFormat=0
1309
1525
  0x28,
1310
- 0x01 // temperatureUnit=1
1526
+ dashboardTemperatureUnit().toByte(),
1527
+ // temperatureUnit
1311
1528
  )
1312
1529
  )
1313
1530
  sendG2SettingCommand(univW.toByteArray())
@@ -1435,11 +1652,11 @@ class G2 : SGCManager() {
1435
1652
  ) // widgetDisplayOrder
1436
1653
  dashDisplayW.writeInt32Field(
1437
1654
  6,
1438
- 1
1655
+ dashboardHalfDayFormat()
1439
1656
  ) // halfDayFormat
1440
1657
  dashDisplayW.writeInt32Field(
1441
1658
  7,
1442
- 1
1659
+ dashboardTemperatureUnit()
1443
1660
  ) // temperatureUnit
1444
1661
 
1445
1662
  val dashRecvW = ProtobufWriter()
@@ -1615,6 +1832,7 @@ class G2 : SGCManager() {
1615
1832
  requestDeviceInfo()
1616
1833
 
1617
1834
  sendMenuApps()
1835
+ sendStoredCalendarEvents()
1618
1836
  },
1619
1837
  500
1620
1838
  )
@@ -1653,8 +1871,8 @@ class G2 : SGCManager() {
1653
1871
  dashDisplayW.writeMessageField(3, byteArrayOf(1, 2, 3)) // statusDisplayOrder
1654
1872
  dashDisplayW.writeInt32Field(4, 4) // widgetDisplayCount
1655
1873
  dashDisplayW.writeMessageField(5, byteArrayOf(1, 3, 2, 2)) // widgetDisplayOrder
1656
- dashDisplayW.writeInt32Field(6, 1) // halfDayFormat
1657
- dashDisplayW.writeInt32Field(7, 1) // temperatureUnit
1874
+ dashDisplayW.writeInt32Field(6, dashboardHalfDayFormat()) // halfDayFormat
1875
+ dashDisplayW.writeInt32Field(7, dashboardTemperatureUnit()) // temperatureUnit
1658
1876
 
1659
1877
  val dashRecvW = ProtobufWriter()
1660
1878
  dashRecvW.writeMessageField(2, dashDisplayW.toByteArray())
@@ -1671,6 +1889,36 @@ class G2 : SGCManager() {
1671
1889
  )
1672
1890
  }
1673
1891
 
1892
+ private fun dashboardHalfDayFormat(): Int {
1893
+ val twelveHour = DeviceStore.get("bluetooth", "twelve_hour_time") as? Boolean ?: true
1894
+ return if (twelveHour) 1 else 0
1895
+ }
1896
+
1897
+ private fun dashboardTemperatureUnit(): Int {
1898
+ val metric = DeviceStore.get("bluetooth", "metric_system") as? Boolean ?: false
1899
+ return if (metric) 1 else 2
1900
+ }
1901
+
1902
+ override fun sendDashboardDisplaySettings() {
1903
+ val dashDisplayW = ProtobufWriter()
1904
+ dashDisplayW.writeInt32Field(1, 4) // displayMode
1905
+ dashDisplayW.writeInt32Field(2, 3) // statusDisplayCount
1906
+ dashDisplayW.writeMessageField(3, byteArrayOf(1, 2, 3)) // statusDisplayOrder
1907
+ dashDisplayW.writeInt32Field(4, 4) // widgetDisplayCount
1908
+ dashDisplayW.writeMessageField(5, byteArrayOf(1, 3, 2, 2)) // widgetDisplayOrder
1909
+ dashDisplayW.writeInt32Field(6, dashboardHalfDayFormat()) // halfDayFormat
1910
+ dashDisplayW.writeInt32Field(7, dashboardTemperatureUnit()) // temperatureUnit
1911
+
1912
+ val dashRecvW = ProtobufWriter()
1913
+ dashRecvW.writeMessageField(2, dashDisplayW.toByteArray())
1914
+
1915
+ val dashPkgW = ProtobufWriter()
1916
+ dashPkgW.writeInt32Field(1, 2) // Dashboard_Receive
1917
+ dashPkgW.writeInt32Field(2, sendManager.nextMagicRandom())
1918
+ dashPkgW.writeMessageField(4, dashRecvW.toByteArray())
1919
+ sendDashboardCommand(dashPkgW.toByteArray())
1920
+ }
1921
+
1674
1922
  // ---------- Heartbeats ----------
1675
1923
 
1676
1924
  private fun startHeartbeats() {
@@ -1760,11 +2008,24 @@ class G2 : SGCManager() {
1760
2008
  }
1761
2009
  }
1762
2010
 
2011
+ private fun sendStoredCalendarEvents() {
2012
+ val calendarEvents =
2013
+ DeviceStore.get("bluetooth", "calendar_events") as? List<Map<String, Any>>
2014
+ ?: emptyList()
2015
+ sendCalendarEvents(calendarEvents)
2016
+ }
2017
+
1763
2018
  // ---------- SGCManager: Display Control ----------
1764
2019
 
1765
2020
  override fun sendTextWall(text: String) {
1766
2021
  // Bridge.log("G2: sendTextWall(${text.take(10)}...)")
1767
2022
 
2023
+ // ignore events while the ER dashboard is open:
2024
+ val useNativeDashboard = DeviceStore.get("bluetooth", "use_native_dashboard") as? Boolean ?: false
2025
+ if (useNativeDashboard && dashboardShowing > 0) {
2026
+ return
2027
+ }
2028
+
1768
2029
  if (text.isEmpty()) {
1769
2030
  clearDisplay()
1770
2031
  return
@@ -1789,121 +2050,113 @@ class G2 : SGCManager() {
1789
2050
  }
1790
2051
  }
1791
2052
 
1792
- override fun displayBitmap(base64ImageData: String): Boolean {
2053
+ /**
2054
+ * Display a bitmap inside a positioned image container.
2055
+ *
2056
+ * The page keeps a live list of up to 4 image containers keyed by exact rect:
2057
+ * - If a container with the requested rect already exists, the image is just resent to it (no
2058
+ * page rebuild).
2059
+ * - Otherwise a new container is added (evicting the oldest when the list would exceed 4) and
2060
+ * the page is rebuilt before the image is sent.
2061
+ *
2062
+ * Omitted params default to a 100x100 container in the top-left corner.
2063
+ */
2064
+ override fun displayBitmap(
2065
+ base64ImageData: String,
2066
+ x: Int?,
2067
+ y: Int?,
2068
+ width: Int?,
2069
+ height: Int?
2070
+ ): Boolean {
1793
2071
  currentBitmapBase64 = base64ImageData
1794
2072
  currentTextContent = ""
1795
- return displayBitmapQuad(base64ImageData)
1796
- }
1797
2073
 
1798
- private fun displayBitmapQuad(base64ImageData: String): Boolean {
2074
+ val rx = x ?: defaultImgX
2075
+ val ry = y ?: defaultImgY
2076
+ val rw = width ?: defaultImgWidth
2077
+ val rh = height ?: defaultImgHeight
2078
+
1799
2079
  val rawData =
1800
2080
  Base64.decode(base64ImageData, Base64.DEFAULT)
1801
2081
  ?: run {
1802
- Bridge.log("G2: displayBitmapQuad() - failed to decode base64")
2082
+ Bridge.log("G2: displayBitmap() - failed to decode base64")
1803
2083
  return false
1804
2084
  }
1805
2085
 
1806
- val tiles =
1807
- renderAndSliceTo4Tiles(rawData)
2086
+ // Create the startup page lazily, seeded with the default top-left container.
2087
+ val freshPage = !startupPageCreated
2088
+ if (freshPage) {
2089
+ imageContainers.clear()
2090
+ imageContainers.add(
2091
+ ImgContainer(
2092
+ id = imageContainerIDPool[0],
2093
+ x = defaultImgX,
2094
+ y = defaultImgY,
2095
+ width = defaultImgWidth,
2096
+ height = defaultImgHeight
2097
+ )
2098
+ )
2099
+ createPageWithText("")
2100
+ Bridge.log("G2: displayBitmap() - startup page created")
2101
+ }
2102
+
2103
+ // Reuse an existing container if the rect matches exactly; otherwise add a new one.
2104
+ val existing = imageContainers.firstOrNull { it.matches(rx, ry, rw, rh) }
2105
+ val container: ImgContainer
2106
+ val needsRebuild: Boolean
2107
+ if (existing != null) {
2108
+ container = existing
2109
+ needsRebuild = false
2110
+ Bridge.log("G2: displayBitmap() - reusing container ${existing.id} for rect $rx,$ry ${rw}x$rh")
2111
+ } else {
2112
+ container = addImageContainer(rx, ry, rw, rh)
2113
+ needsRebuild = true
2114
+ Bridge.log("G2: displayBitmap() - added container ${container.id} for rect $rx,$ry ${rw}x$rh, rebuilding page")
2115
+ rebuildPage()
2116
+ }
2117
+
2118
+ val bmpData =
2119
+ convertToG2Bmp(rawData, containerWidth = container.width, containerHeight = container.height)
1808
2120
  ?: run {
1809
- Bridge.log(
1810
- "G2: displayBitmapQuad() - failed to slice image into tiles"
1811
- )
2121
+ Bridge.log("G2: displayBitmap() - failed to convert image to BMP")
1812
2122
  return false
1813
2123
  }
1814
2124
 
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
- )
1868
- }
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
- )
2125
+ // Fire-and-forget. If the page was just created/rebuilt, defer the BMP send 1s to let the
2126
+ // glasses process the page (matches iOS's await). Reuse path sends immediately.
2127
+ if (freshPage || needsRebuild) {
2128
+ mainHandler.postDelayed(
2129
+ { sendImageData(container.id, container.name, bmpData) },
2130
+ 1000
2131
+ )
2132
+ } else {
2133
+ sendImageData(container.id, container.name, bmpData)
2134
+ }
1893
2135
 
1894
2136
  return true
1895
2137
  }
1896
2138
 
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)
2139
+ /**
2140
+ * Add a new image container for the rect, evicting the oldest when the list is full (max 4).
2141
+ * Returns the newly tracked container (with an assigned ID from the pool).
2142
+ */
2143
+ private fun addImageContainer(x: Int, y: Int, width: Int, height: Int): ImgContainer {
2144
+ // Evict the oldest container when at capacity, freeing its ID for reuse.
2145
+ if (imageContainers.size >= imageContainerIDPool.size) {
2146
+ val evicted = imageContainers.removeAt(0)
2147
+ Bridge.log("G2: evicting oldest image container ${evicted.id}")
1906
2148
  }
2149
+ // Pick the lowest free ID from the pool.
2150
+ val usedIDs = imageContainers.map { it.id }.toSet()
2151
+ val id = imageContainerIDPool.firstOrNull { it !in usedIDs } ?: imageContainerIDPool[0]
2152
+ val container = ImgContainer(id = id, x = x, y = y, width = width, height = height)
2153
+ imageContainers.add(container)
2154
+ return container
2155
+ }
2156
+
2157
+ /** Rebuild the page from the current text + image container list. */
2158
+ private fun rebuildPage() {
2159
+ createPageWithText(currentTextContent)
1907
2160
  }
1908
2161
 
1909
2162
  private fun sendImageData(
@@ -1948,9 +2201,8 @@ class G2 : SGCManager() {
1948
2201
  fragmentIndex++
1949
2202
  offset = end
1950
2203
 
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).
2204
+ // 200ms between fragments — and also before onComplete (matches iOS, which awaits
2205
+ // 200ms after every fragment including the last).
1954
2206
  if (offset < bmpData.size) {
1955
2207
  mainHandler.postDelayed({ sendNextFragment() }, 200)
1956
2208
  } else {
@@ -1964,93 +2216,23 @@ class G2 : SGCManager() {
1964
2216
  sendNextFragment()
1965
2217
  }
1966
2218
 
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
- }
2051
-
2219
+ /// Bring the Even Realities dashboard (the OS-level home/idle screen) to
2220
+ /// the foreground by tearing down whatever EvenHub page we currently own.
2221
+ /// The glasses fall back to the dashboard automatically when no page is up.
2052
2222
  override fun showDashboard() {
2053
- // G2 doesn't have a native dashboard concept via EvenHub
2223
+ Bridge.log("G2: showDashboard()")
2224
+ dashboardShowing += 2
2225
+ val msg = EvenHubProto.shutdownMessage()
2226
+ sendEvenHubCommand(msg)
2227
+ pageCreated = false
2228
+ pageHasTextContainer = false
2229
+ currentTextContent = ""
2230
+ currentBitmapBase64 = ""
2231
+ mainHandler.postDelayed({
2232
+ // activate the dashboard by setting depth to the current setting:
2233
+ val currentDepth = DeviceStore.get("bluetooth", "dashboard_depth") as? Int ?: 0
2234
+ setDashboardDepthOnly(currentDepth)
2235
+ }, 500)
2054
2236
  }
2055
2237
 
2056
2238
  override fun setDashboardPosition(height: Int, depth: Int) {
@@ -2091,6 +2273,95 @@ class G2 : SGCManager() {
2091
2273
  sendMenuCommand(msg)
2092
2274
  }
2093
2275
 
2276
+ /**
2277
+ * Bridge entry for `calendar_events` store updates. Each map is expected to match
2278
+ * the TS `CalendarEvent` shape: { title, location?, time, endDate } where `endDate`
2279
+ * is Unix seconds.
2280
+ *
2281
+ * Sends one BLE push per event, with `scheduleTotal` set to the batch size and
2282
+ * `scheduleNum` set to this event's 0-based slot. The widget pages through them on
2283
+ * the glasses — without paging info the firmware overwrites slot 0 on each push and
2284
+ * only the last event survives.
2285
+ */
2286
+ override fun sendCalendarEvents(events: List<Map<String, Any>>) {
2287
+ Bridge.log("G2: sendCalendarEvents -- ${events.size} events")
2288
+ if (events.isEmpty()) {
2289
+ sendDashboardCommand(
2290
+ CalendarProto.calendarClear(
2291
+ magicRandom = sendManager.nextMagicRandom(),
2292
+ packageId = 1,
2293
+ scheduleAuthority = 1
2294
+ )
2295
+ )
2296
+ return
2297
+ }
2298
+
2299
+ val total = events.size
2300
+ // Fold the local TZ offset into the timestamp so the glasses (which treat
2301
+ // timestamps as already-local) display the correct time — same hack as time-sync.
2302
+ val tzSec = TimeZone.getDefault().getOffset(System.currentTimeMillis()) / 1000
2303
+ events.forEachIndexed { i, ev ->
2304
+ val title = ev["title"] as? String ?: return@forEachIndexed
2305
+ val time = ev["time"] as? String ?: return@forEachIndexed
2306
+ val endTs = (ev["endDate"] as? Number)?.toLong() ?: return@forEachIndexed
2307
+ val location = ev["location"] as? String
2308
+
2309
+ val payload =
2310
+ CalendarProto.calendarPush(
2311
+ magicRandom = sendManager.nextMagicRandom(),
2312
+ packageId = 1,
2313
+ scheduleId = i + 1,
2314
+ title = title,
2315
+ location = location,
2316
+ time = time,
2317
+ endTimestamp = (endTs + tzSec).toInt(),
2318
+ scheduleAuthority = 1,
2319
+ scheduleTotal = total,
2320
+ scheduleNum = i
2321
+ )
2322
+ sendDashboardCommand(payload)
2323
+ }
2324
+ }
2325
+
2326
+ /**
2327
+ * Open the on-glasses notification panel — same effect as the user saying
2328
+ * "Hey Even, show notifications". Replicates the official-app voice flow:
2329
+ * 1. CTRL{status=ENTER} — puts glasses in AI session
2330
+ * 2. ASK{text=" "} — minimal ASR transcript to seed session context
2331
+ * 3. SKILL{skillId=NOTIFICATION, skillParam=show, ...} — dispatches the intent
2332
+ * The SKILL step alone is ignored by firmware; the preceding ENTER+ASK
2333
+ * supply the session context that lets the glasses act on the SKILL.
2334
+ */
2335
+ override fun showNotificationsPanel() {
2336
+ Bridge.log("G2: showNotificationsPanel()")
2337
+ val enterPayload = EvenAIProto.aiCtrl(
2338
+ magicRandom = sendManager.nextMagicRandom(),
2339
+ status = 2 // EVEN_AI_ENTER
2340
+ )
2341
+ sendEvenAICommand(enterPayload)
2342
+
2343
+ mainHandler.postDelayed({
2344
+ val askPayload = EvenAIProto.aiAsk(
2345
+ magicRandom = sendManager.nextMagicRandom(),
2346
+ text = " ",
2347
+ streamEnable = 0
2348
+ )
2349
+ sendEvenAICommand(askPayload)
2350
+
2351
+ mainHandler.postDelayed({
2352
+ val skillPayload = EvenAIProto.triggerSkill(
2353
+ magicRandom = sendManager.nextMagicRandom(),
2354
+ skillId = 3, // NOTIFICATION
2355
+ skillParam = 1, // show
2356
+ text = " ",
2357
+ streamEnable = 1,
2358
+ fTextEnd = 1
2359
+ )
2360
+ sendEvenAICommand(skillPayload)
2361
+ }, 400)
2362
+ }, 400)
2363
+ }
2364
+
2094
2365
  override fun setBrightness(level: Int, autoMode: Boolean) {
2095
2366
  Bridge.log("G2: setBrightness($level, auto=$autoMode)")
2096
2367
  val msg =
@@ -2121,12 +2392,26 @@ class G2 : SGCManager() {
2121
2392
  content = text
2122
2393
  )
2123
2394
 
2395
+ // Build the page's image containers from the live tracked list.
2396
+ val imageContainerProps: List<ByteArray> =
2397
+ imageContainers.map { c ->
2398
+ EvenHubProto.imageContainerProperty(
2399
+ x = c.x,
2400
+ y = c.y,
2401
+ width = c.width,
2402
+ height = c.height,
2403
+ containerID = c.id,
2404
+ containerName = c.name
2405
+ )
2406
+ }
2407
+
2124
2408
  val msg: ByteArray
2125
2409
  if (!startupPageCreated) {
2126
2410
  Bridge.log("G2: createPageWithText - using createPageMessage (first time)")
2127
2411
  msg =
2128
2412
  EvenHubProto.createPageMessage(
2129
2413
  textContainers = listOf(tc),
2414
+ imageContainers = imageContainerProps,
2130
2415
  magicRandom = sendManager.nextMagicRandom(),
2131
2416
  appId = activeMenuAppId
2132
2417
  )
@@ -2136,6 +2421,7 @@ class G2 : SGCManager() {
2136
2421
  msg =
2137
2422
  EvenHubProto.rebuildPageMessage(
2138
2423
  textContainers = listOf(tc),
2424
+ imageContainers = imageContainerProps,
2139
2425
  magicRandom = sendManager.nextMagicRandom(),
2140
2426
  appId = activeMenuAppId
2141
2427
  )
@@ -2327,21 +2613,33 @@ class G2 : SGCManager() {
2327
2613
 
2328
2614
  // ---------- SGCManager: Audio Control ----------
2329
2615
 
2616
+ fun restartMic() {
2617
+ // if already enabled, set to disabled, then send enabled after 500ms:
2618
+ DeviceStore.apply("glasses", "micEnabled", true)
2619
+ val msg = EvenHubProto.audioControlMessage(false)
2620
+ sendEvenHubCommand(msg)
2621
+ mainHandler.postDelayed({
2622
+ val useNativeDashboard = DeviceStore.get("bluetooth", "use_native_dashboard") as? Boolean ?: false
2623
+ Bridge.log("G2: setMicEnabled - useNativeDashboard=$useNativeDashboard, dashboardShowing=$dashboardShowing")
2624
+ if (useNativeDashboard && dashboardShowing > 0) {
2625
+ return@postDelayed
2626
+ }
2627
+ if (!pageCreated || !pageHasTextContainer) {
2628
+ DeviceManager.getInstance().sendCurrentState() // should re-create the page if needed
2629
+ }
2630
+ val msg = EvenHubProto.audioControlMessage(true)
2631
+ sendEvenHubCommand(msg)
2632
+ }, 500)
2633
+ }
2634
+
2330
2635
  override fun setMicEnabled(enabled: Boolean) {
2331
2636
  Bridge.log("G2: setMicEnabled($enabled)")
2332
2637
  val currentEnabled = DeviceStore.get("glasses", "micEnabled") as? Boolean ?: false
2333
-
2334
- // if already enabled, set to disabled, then send enabled after 500ms:
2335
2638
  if (currentEnabled && enabled) {
2336
- DeviceStore.apply("glasses", "micEnabled", true)
2337
- val msg = EvenHubProto.audioControlMessage(false)
2338
- sendEvenHubCommand(msg)
2339
- mainHandler.postDelayed({
2340
- val msg = EvenHubProto.audioControlMessage(true)
2341
- sendEvenHubCommand(msg)
2342
- }, 500)
2639
+ restartMic()
2343
2640
  return
2344
2641
  }
2642
+
2345
2643
  DeviceStore.apply("glasses", "micEnabled", enabled)
2346
2644
  val msg = EvenHubProto.audioControlMessage(enabled)
2347
2645
  sendEvenHubCommand(msg)
@@ -2360,6 +2658,7 @@ class G2 : SGCManager() {
2360
2658
  authToken: String?,
2361
2659
  compress: String?,
2362
2660
  flash: Boolean,
2661
+ save: Boolean,
2363
2662
  sound: Boolean,
2364
2663
  exposureTimeNs: Long?,
2365
2664
  iso: Int?,
@@ -2464,6 +2763,8 @@ class G2 : SGCManager() {
2464
2763
  startupPageCreated = false
2465
2764
  pageCreated = false
2466
2765
  pageHasTextContainer = false
2766
+ imageContainers.clear()
2767
+ dashboardShowing = 0
2467
2768
  heartbeatCounter = 0
2468
2769
  currentBitmapBase64 = ""
2469
2770
  menuAppIdToPackageName.clear()
@@ -2506,7 +2807,7 @@ class G2 : SGCManager() {
2506
2807
  }
2507
2808
 
2508
2809
  override fun dbg1() {
2509
- connectController()
2810
+ showNotificationsPanel()
2510
2811
  }
2511
2812
  override fun dbg2() {
2512
2813
  disconnectController()
@@ -2606,6 +2907,14 @@ class G2 : SGCManager() {
2606
2907
  // TODO: Implement via dev_settings
2607
2908
  }
2608
2909
 
2910
+ /// Push the current time to the glasses. Useful after DST transitions,
2911
+ /// time-zone travel, or a long sleep where the glasses' clock has drifted.
2912
+ fun syncTime() {
2913
+ Bridge.log("G2: syncTime()")
2914
+ val msg = DevSettingsProto.timeSync(sendManager.nextMagicRandom())
2915
+ sendDevSettingsCommand(msg, left = true, right = true)
2916
+ }
2917
+
2609
2918
  override fun sendRgbLedControl(
2610
2919
  requestId: String,
2611
2920
  packageName: String?,
@@ -2940,6 +3249,7 @@ class G2 : SGCManager() {
2940
3249
  startupPageCreated = false
2941
3250
  pageCreated = false
2942
3251
  pageHasTextContainer = false
3252
+ dashboardShowing = 0
2943
3253
  DeviceStore.apply("glasses", "connected", false)
2944
3254
  DeviceStore.apply("glasses", "fullyBooted", false)
2945
3255
 
@@ -3188,7 +3498,11 @@ class G2 : SGCManager() {
3188
3498
  }
3189
3499
  (resFields[8] as? Int)?.let { errorCode ->
3190
3500
  // ImgResCmd has ErrorCode in field 8
3191
- Bridge.log("G2: EvenHub ImgRes errorCode=$errorCode")
3501
+ if (errorCode == 4) {
3502
+ Bridge.log("G2: img_success")
3503
+ } else {
3504
+ Bridge.log("G2: EvenHub ImgRes errorCode=$errorCode")
3505
+ }
3192
3506
  }
3193
3507
  }
3194
3508
 
@@ -3269,12 +3583,14 @@ class G2 : SGCManager() {
3269
3583
  if (eventType == OsEventType.DOUBLE_CLICK) {
3270
3584
  // trigger dashboard:
3271
3585
  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
- // }
3586
+
3587
+ val useNativeDashboard = DeviceStore.get("bluetooth", "use_native_dashboard") as? Boolean ?: false
3588
+ if (useNativeDashboard) {
3589
+ showDashboard()
3590
+ } else {
3591
+ // toggle head up:
3592
+ DeviceStore.apply("glasses", "headUp", !isHeadUp)
3593
+ }
3278
3594
  }
3279
3595
 
3280
3596
  // System exit: glasses killed our EvenHub page (user opened menu or another app)
@@ -3445,14 +3761,47 @@ class G2 : SGCManager() {
3445
3761
  }
3446
3762
 
3447
3763
  private fun handleGestureCtrl(payload: ByteArray) {
3764
+ // Dedup: L and R peripherals both deliver this event, so debounce within 500ms.
3765
+ val timestamp = System.currentTimeMillis()
3766
+ val last = lastGestureCtrlTimestamp
3767
+ if (last != null && timestamp - last < 500) {
3768
+ Bridge.log("G2: gesture_ctrl dedup")
3769
+ return
3770
+ }
3771
+ lastGestureCtrlTimestamp = timestamp
3772
+
3448
3773
  // Dashboard close detection: 08011A00 means dashboard closed
3449
3774
  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(" ")
3775
+ Bridge.log("G2: dashboard closed / shutdown - dashboardShowing=$dashboardShowing")
3776
+ val useNativeDashboard = DeviceStore.get("bluetooth", "use_native_dashboard") as? Boolean ?: false
3777
+ if (!useNativeDashboard) {
3778
+ // make sure the container exists:
3779
+ DeviceManager.getInstance().sendCurrentState()
3780
+ // re-send mic on / if it's enabled:
3781
+ val micEnabled = DeviceStore.get("glasses", "micEnabled") as? Boolean ?: false
3782
+ if (micEnabled) {
3783
+ restartMic()
3784
+ }
3785
+ } else {
3786
+ // if we aren't trying to show the dashboard
3787
+ // then we need to turn the mic back on and display the mentra main page:
3788
+ if (dashboardShowing <= 1) {
3789
+ dashboardShowing = 0
3790
+ // make sure the container exists:
3791
+ DeviceManager.getInstance().sendCurrentState()
3792
+ // set the mic back on if it should be on
3793
+ val micEnabled = DeviceStore.get("glasses", "micEnabled") as? Boolean ?: false
3794
+ if (micEnabled) {
3795
+ restartMic()
3796
+ }
3797
+ return
3798
+ }
3799
+ // do nothing this time since we just closed the dashboard
3800
+ dashboardShowing -= 1
3801
+ if (dashboardShowing < 0) {
3802
+ dashboardShowing = 0
3803
+ }
3804
+ }
3456
3805
  }
3457
3806
  }
3458
3807