@mentra/bluetooth-sdk 0.1.9 → 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 +6 -0
  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
@@ -53,6 +53,7 @@ private enum G2BLE {
53
53
  private enum ServiceID: UInt8 {
54
54
  case dashboard = 1 // 0x01 - UI_BACKGROUND_DASHBOARD_APP_ID
55
55
  case menu = 3 // 0x03 - UI_FOREGROUND_MEUN_ID (typo is intentional — matches Even's proto)
56
+ case notification = 4 // 0x04 - UI_FOREGROUND_NOTIFICATION_ID
56
57
  case evenAI = 7 // 0x07 - UI_FOREGROUND_EVEN_AI_ID
57
58
  case g2Setting = 9 // 0x09 - UI_SETTING_APP_ID
58
59
  case gestureCtrl = 13 // 0x0D - gesture_ctrl lifecycle signals
@@ -150,6 +151,12 @@ private struct ProtobufWriter {
150
151
  }
151
152
  }
152
153
 
154
+ mutating func writeInt64Field(_ fieldNumber: Int, _ value: Int64) {
155
+ let tag = UInt64(fieldNumber << 3) | 0 // wire type 0 = varint
156
+ writeVarint(tag)
157
+ writeVarint(UInt64(bitPattern: value))
158
+ }
159
+
153
160
  mutating func writeStringField(_ fieldNumber: Int, _ value: String) {
154
161
  let tag = UInt64(fieldNumber << 3) | 2 // wire type 2 = length-delimited
155
162
  writeVarint(tag)
@@ -518,22 +525,51 @@ private enum DevSettingsProto {
518
525
  return w.data
519
526
  }
520
527
 
521
- /// DevCfgDataPackage with TIME_SYNC command
528
+ /// DevCfgDataPackage with TIME_SYNC command.
529
+ /// TimeSync submessage: f1 = (Unix seconds + TZ offset seconds) as Int32, no TZ field.
530
+ /// Firmware appears to ignore the TZ field, so we pre-shift the timestamp itself
531
+ /// to make UTC interpretation read as local. Empirically confirmed via probe variants in dbg1().
522
532
  static func timeSync(magicRandom: Int32) -> Data {
523
533
  var w = ProtobufWriter()
524
534
  w.writeInt32Field(1, DevCfgCommandId.timeSync.rawValue)
525
535
  w.writeInt32Field(2, magicRandom)
526
536
 
527
- // TimeSync: field 1 = timestamp (int32), field 2 = timezone (int32)
528
537
  var tsW = ProtobufWriter()
529
- let timestamp = Int32(Date().timeIntervalSince1970)
530
- tsW.writeInt32Field(1, timestamp)
531
- let tz = Int32(TimeZone.current.secondsFromGMT() / 3600)
532
- tsW.writeInt32Field(2, tz)
538
+ let nowSec = Int64(Date().timeIntervalSince1970)
539
+ let tzSec = Int64(TimeZone.current.secondsFromGMT())
540
+ tsW.writeInt32Field(1, Int32(truncatingIfNeeded: nowSec + tzSec))
533
541
  w.writeMessageField(128, tsW.data) // timeSync (field 128 in DevCfgDataPackage)
534
542
  return w.data
535
543
  }
536
544
 
545
+ /// Parameterized TIME_SYNC for probing the right wire format from dbg1().
546
+ /// - tsField: protobuf field # for the timestamp varint (typically 1)
547
+ /// - tsValue: raw timestamp varint value
548
+ /// - tsBits64: encode timestamp as Int64 (true) or Int32 (false)
549
+ /// - tzField: protobuf field # for TZ (nil to omit entirely)
550
+ /// - tzValue: TZ value to write if tzField != nil
551
+ static func timeSyncVariant(
552
+ magicRandom: Int32,
553
+ tsField: Int, tsValue: Int64, tsBits64: Bool,
554
+ tzField: Int?, tzValue: Int32
555
+ ) -> Data {
556
+ var w = ProtobufWriter()
557
+ w.writeInt32Field(1, DevCfgCommandId.timeSync.rawValue)
558
+ w.writeInt32Field(2, magicRandom)
559
+
560
+ var tsW = ProtobufWriter()
561
+ if tsBits64 {
562
+ tsW.writeInt64Field(tsField, tsValue)
563
+ } else {
564
+ tsW.writeInt32Field(tsField, Int32(truncatingIfNeeded: tsValue))
565
+ }
566
+ if let tzField = tzField {
567
+ tsW.writeInt32Field(tzField, tzValue)
568
+ }
569
+ w.writeMessageField(128, tsW.data)
570
+ return w.data
571
+ }
572
+
537
573
  /// DevCfgDataPackage with BASE_CONNECT_HEART_BEAT command
538
574
  static func baseHeartbeat(magicRandom: Int32) -> Data {
539
575
  var w = ProtobufWriter()
@@ -716,6 +752,108 @@ private enum EvenAIProto {
716
752
  w.writeMessageField(13, configW.data) // config (field 13)
717
753
  return w.data
718
754
  }
755
+
756
+ /// EvenAIDataPackage with ASK command — what the phone sends after cloud
757
+ /// ASR resolves the user's audio into text. Mirrors Flutter `sendAsr`:
758
+ /// `EvenAIAskInfo { text, streamEnable=0 }`. Used to inject an ASR result
759
+ /// into the glasses' AI session so the following SKILL packet has context.
760
+ static func aiAsk(magicRandom: Int32, text: String, streamEnable: Int32 = 0) -> Data {
761
+ var askW = ProtobufWriter()
762
+ askW.writeInt32Field(2, streamEnable) // streamEnable
763
+ askW.writeBytesField(4, Data(text.utf8)) // text
764
+
765
+ var w = ProtobufWriter()
766
+ w.writeInt32Field(1, 3) // commandId = ASK
767
+ w.writeInt32Field(2, magicRandom)
768
+ w.writeMessageField(5, askW.data) // askInfo (field 5)
769
+ return w.data
770
+ }
771
+
772
+ /// EvenAIDataPackage with CTRL command — used to put glasses into / out of
773
+ /// an AI session. Mirrors Flutter `sendWakeupResp`, which sends
774
+ /// `EvenAIControl { status = EVEN_AI_ENTER }` after the glasses send WAKE_UP.
775
+ /// status: 1 WAKE_UP, 2 ENTER, 3 EXIT
776
+ static func aiCtrl(magicRandom: Int32, status: Int32) -> Data {
777
+ var ctrlW = ProtobufWriter()
778
+ ctrlW.writeInt32Field(1, status) // status
779
+
780
+ var w = ProtobufWriter()
781
+ w.writeInt32Field(1, 1) // commandId = CTRL
782
+ w.writeInt32Field(2, magicRandom)
783
+ w.writeMessageField(3, ctrlW.data) // ctrl (field 3)
784
+ return w.data
785
+ }
786
+
787
+ /// EvenAIDataPackage with SKILL command — triggers a built-in glasses UI
788
+ /// the same way the "Hey Even, show X" voice command does.
789
+ /// skillId values (per even_ai.proto):
790
+ /// 0 SKILL_NONE, 1 BRIGHTNESS, 2 TRANSLATE_CTRL, 3 NOTIFICATION,
791
+ /// 4 TELEPROMPT, 5 NAVIGATE, 6 CONVERSATE, 7 QUICKLIST, 8 AUTO_BRIGHTNESS
792
+ static func triggerSkill(
793
+ magicRandom: Int32, skillId: Int32, skillParam: Int32 = 0,
794
+ text: String = "", streamEnable: Int32 = 1, fTextEnd: Int32 = 1
795
+ ) -> Data {
796
+ // EvenAISkillInfo
797
+ var skillW = ProtobufWriter()
798
+ skillW.writeInt32Field(1, streamEnable) // streamEnable
799
+ skillW.writeInt32Field(2, skillId) // skillId
800
+ skillW.writeInt32Field(3, skillParam) // skillParam — for NOTIFICATION skill this is a NotificationType enum
801
+ skillW.writeBytesField(4, Data(text.utf8)) // text (utterance / payload)
802
+ skillW.writeInt32Field(6, fTextEnd) // fTextEnd — 1 signals "this is the final/complete packet"
803
+
804
+ // EvenAIDataPackage
805
+ var w = ProtobufWriter()
806
+ w.writeInt32Field(1, 6) // commandId = SKILL
807
+ w.writeInt32Field(2, magicRandom)
808
+ w.writeMessageField(8, skillW.data) // skillInfo (field 8)
809
+ return w.data
810
+ }
811
+ }
812
+
813
+ // MARK: - Notification Protobuf Builders (notification.proto, service ID 4)
814
+
815
+ private enum NotificationProto {
816
+ /// NotificationDataPackage with commandId=NOTIFICATION_IOS (2), carrying
817
+ /// `NotificationIOS { appID, displayName }`. We saw the glasses emit this
818
+ /// inbound after "Hey Even, show notifications" with appID="com.burbn.instagram";
819
+ /// trying the same shape outbound to see if the glasses display it.
820
+ /// (Returned errorCode=8 NOT_SUPPORT in testing — Service 4 doesn't accept this outbound.)
821
+ static func iosNotification(magicRandom: Int32, appID: String, displayName: String) -> Data {
822
+ var iosW = ProtobufWriter()
823
+ iosW.writeBytesField(1, Data(appID.utf8)) // appID
824
+ iosW.writeBytesField(2, Data(displayName.utf8)) // displayName
825
+
826
+ var w = ProtobufWriter()
827
+ w.writeInt32Field(1, 2) // commandId = NOTIFICATION_IOS
828
+ w.writeInt32Field(2, magicRandom)
829
+ w.writeMessageField(4, iosW.data) // IOS (field 4)
830
+ return w.data
831
+ }
832
+
833
+ /// NotificationDataPackage with commandId=NOTIFICATION_CTRL (1), carrying
834
+ /// `NotificationControl { notifEnable, autoDispEnable, dispTime, avoidDisturbEnable }`.
835
+ /// Per Flutter `ProtoNotificationExt.settingNotification` this is how the
836
+ /// official app configures notification behavior on the glasses. Worth
837
+ /// testing whether toggling notifEnable also opens the notification panel.
838
+ static func notificationCtrl(
839
+ magicRandom: Int32,
840
+ notifEnable: Int32 = 1,
841
+ autoDispEnable: Int32 = 1,
842
+ dispTime: Int32 = 5,
843
+ avoidDisturbEnable: Int32 = 0
844
+ ) -> Data {
845
+ var ctrlW = ProtobufWriter()
846
+ ctrlW.writeInt32Field(1, notifEnable) // notifEnable
847
+ ctrlW.writeInt32Field(2, autoDispEnable) // autoDispEnable
848
+ ctrlW.writeInt32Field(3, dispTime) // dispTime (seconds)
849
+ ctrlW.writeInt32Field(5, avoidDisturbEnable) // avoidDisturbEnable
850
+
851
+ var w = ProtobufWriter()
852
+ w.writeInt32Field(1, 1) // commandId = NOTIFICATION_CTRL
853
+ w.writeInt32Field(2, magicRandom)
854
+ w.writeMessageField(3, ctrlW.data) // ctrl (field 3)
855
+ return w.data
856
+ }
719
857
  }
720
858
 
721
859
  // MARK: - Menu Protobuf Builders (menu.proto, service ID 3)
@@ -822,6 +960,142 @@ private enum MenuProto {
822
960
  }
823
961
  }
824
962
 
963
+ // MARK: - Dashboard Protobuf Builders (dashboard.proto, service ID 1)
964
+
965
+ /// Builders for the dashboard widget service (service 0x01).
966
+ /// Field numbers come from the extracted dashboard.proto v2.1.0_beta_v3.
967
+ private enum DashboardProto {
968
+ /// eDashboardCommandId values from dashboard.proto
969
+ enum CommandId: Int32 {
970
+ case dashboardRespond = 1
971
+ case dashboardReceive = 2 // phone → glasses widget/config push
972
+ case appRespond = 3
973
+ case appReceive = 4
974
+ }
975
+
976
+ /// Build a Schedule submessage (the calendar event payload).
977
+ /// f1 = scheduleId (int32, required)
978
+ /// f2 = title (string, optional)
979
+ /// f3 = location (string, optional)
980
+ /// f4 = time (string, optional — display text e.g. "10:00 AM")
981
+ /// f5 = endTimestamp (int32, presumed Unix seconds — pre-shift by TZ
982
+ /// to match the time-sync hack so glasses display local time)
983
+ static func schedule(
984
+ scheduleId: Int32,
985
+ title: String?,
986
+ location: String?,
987
+ time: String?,
988
+ endTimestamp: Int32
989
+ ) -> Data {
990
+ var w = ProtobufWriter()
991
+ w.writeInt32Field(1, scheduleId)
992
+ if let title = title { w.writeStringField(2, title) }
993
+ if let location = location { w.writeStringField(3, location) }
994
+ if let time = time { w.writeStringField(4, time) }
995
+ w.writeInt32Field(5, endTimestamp)
996
+ return w.data
997
+ }
998
+
999
+ /// Build an rScheduleWidget wrapping a single Schedule.
1000
+ /// f1 = scheduleTotal, f2 = scheduleNum (0-based), f3 = Schedule, f4 = scheduleAuthority
1001
+ static func rScheduleWidget(
1002
+ scheduleTotal: Int32,
1003
+ scheduleNum: Int32,
1004
+ schedule: Data,
1005
+ scheduleAuthority: Int32
1006
+ ) -> Data {
1007
+ var w = ProtobufWriter()
1008
+ w.writeInt32Field(1, scheduleTotal)
1009
+ w.writeInt32Field(2, scheduleNum)
1010
+ w.writeMessageField(3, schedule)
1011
+ w.writeInt32Field(4, scheduleAuthority)
1012
+ return w.data
1013
+ }
1014
+
1015
+ /// Build the full calendar-push DashboardDataPackage:
1016
+ /// DashboardDataPackage {
1017
+ /// commandId = Dashboard_Receive (2)
1018
+ /// magicRandom
1019
+ /// dashboardReceive = DashboardReceiveFromApp {
1020
+ /// packageId = 1
1021
+ /// bashboardConfig = DashboardContent {
1022
+ /// widgetComponents = rWidgetComponent {
1023
+ /// schedule = rScheduleWidget { ... }
1024
+ /// }
1025
+ /// }
1026
+ /// }
1027
+ /// }
1028
+ static func calendarPush(
1029
+ magicRandom: Int32,
1030
+ packageId: Int32,
1031
+ scheduleId: Int32,
1032
+ title: String?,
1033
+ location: String?,
1034
+ time: String?,
1035
+ endTimestamp: Int32,
1036
+ scheduleAuthority: Int32,
1037
+ scheduleTotal: Int32 = 1,
1038
+ scheduleNum: Int32 = 0
1039
+ ) -> Data {
1040
+ let sched = schedule(
1041
+ scheduleId: scheduleId, title: title, location: location,
1042
+ time: time, endTimestamp: endTimestamp
1043
+ )
1044
+ let rSched = rScheduleWidget(
1045
+ scheduleTotal: scheduleTotal, scheduleNum: scheduleNum,
1046
+ schedule: sched, scheduleAuthority: scheduleAuthority
1047
+ )
1048
+
1049
+ // rWidgetComponent { f3 = rScheduleWidget }
1050
+ var rWidget = ProtobufWriter()
1051
+ rWidget.writeMessageField(3, rSched)
1052
+
1053
+ // DashboardContent { f2 = rWidgetComponent }
1054
+ var content = ProtobufWriter()
1055
+ content.writeMessageField(2, rWidget.data)
1056
+
1057
+ // DashboardReceiveFromApp { f1 = packageId, f3 = DashboardContent }
1058
+ var receive = ProtobufWriter()
1059
+ receive.writeInt32Field(1, packageId)
1060
+ receive.writeMessageField(3, content.data)
1061
+
1062
+ // DashboardDataPackage { f1 = commandId, f2 = magicRandom, f4 = dashboardReceive }
1063
+ var pkg = ProtobufWriter()
1064
+ pkg.writeInt32Field(1, CommandId.dashboardReceive.rawValue)
1065
+ pkg.writeInt32Field(2, magicRandom)
1066
+ pkg.writeMessageField(4, receive.data)
1067
+ return pkg.data
1068
+ }
1069
+
1070
+ static func calendarClear(
1071
+ magicRandom: Int32,
1072
+ packageId: Int32,
1073
+ scheduleAuthority: Int32
1074
+ ) -> Data {
1075
+ // rScheduleWidget with scheduleTotal=0 clears the widget without sending a stale Schedule.
1076
+ var rSched = ProtobufWriter()
1077
+ rSched.writeInt32Field(1, 0)
1078
+ rSched.writeInt32Field(2, 0)
1079
+ rSched.writeInt32Field(4, scheduleAuthority)
1080
+
1081
+ var rWidget = ProtobufWriter()
1082
+ rWidget.writeMessageField(3, rSched.data)
1083
+
1084
+ var content = ProtobufWriter()
1085
+ content.writeMessageField(2, rWidget.data)
1086
+
1087
+ var receive = ProtobufWriter()
1088
+ receive.writeInt32Field(1, packageId)
1089
+ receive.writeMessageField(3, content.data)
1090
+
1091
+ var pkg = ProtobufWriter()
1092
+ pkg.writeInt32Field(1, CommandId.dashboardReceive.rawValue)
1093
+ pkg.writeInt32Field(2, magicRandom)
1094
+ pkg.writeMessageField(4, receive.data)
1095
+ return pkg.data
1096
+ }
1097
+ }
1098
+
825
1099
  // MARK: - EvenBLE Transport Layer
826
1100
 
827
1101
  /// Builds and splits payloads into BLE packets with the EvenHub transport framing
@@ -1136,6 +1410,28 @@ class G2: NSObject, SGCManager {
1136
1410
  private var activeMenuAppId: Int32?
1137
1411
  private var lastClickTimestamp: Int64?
1138
1412
  private var lastMenuSelectTimestamp: Int64?
1413
+ private var lastGestureCtrlTimestamp: Int64?
1414
+
1415
+ /// A tracked image container on the current page. Keyed by its rect for reuse.
1416
+ private struct ImgContainer: Equatable {
1417
+ let id: Int32
1418
+ let x: Int32
1419
+ let y: Int32
1420
+ let width: Int32
1421
+ let height: Int32
1422
+ var name: String { "img-\(id)" }
1423
+ func matches(x: Int32, y: Int32, width: Int32, height: Int32) -> Bool {
1424
+ self.x == x && self.y == y && self.width == width && self.height == height
1425
+ }
1426
+ }
1427
+
1428
+ /// Live list of image containers on the page, ordered oldest→newest (for LRU eviction).
1429
+ /// The page may hold at most 4 image containers (IDs from the pool below).
1430
+ private var imageContainers: [ImgContainer] = []
1431
+ /// Fixed pool of container IDs the page protocol expects.
1432
+ private let imageContainerIDPool: [Int32] = [10, 11, 12, 13]
1433
+ /// Default container seeded into every fresh page: 100x100 in the top-left.
1434
+ private static let defaultImgContainer = (x: Int32(288), y: Int32(144), width: Int32(200), height: Int32(100))
1139
1435
 
1140
1436
  @Published var aiListening: Bool = false
1141
1437
 
@@ -1213,6 +1509,15 @@ class G2: NSObject, SGCManager {
1213
1509
  sendToGlasses(packets)
1214
1510
  }
1215
1511
 
1512
+ private func sendNotificationCommand(_ payload: Data) {
1513
+ let packets = sendManager.buildPackets(
1514
+ serviceId: ServiceID.notification.rawValue,
1515
+ payload: payload,
1516
+ reserveFlag: true
1517
+ )
1518
+ sendToGlasses(packets)
1519
+ }
1520
+
1216
1521
  private func sendMenuCommand(_ payload: Data) {
1217
1522
  let packets = sendManager.buildPackets(
1218
1523
  serviceId: ServiceID.menu.rawValue,
@@ -1246,7 +1551,7 @@ class G2: NSObject, SGCManager {
1246
1551
  payload: payload,
1247
1552
  reserveFlag: true
1248
1553
  )
1249
- sendToGlasses(packets)
1554
+ sendToGlasses(packets, left: true, right: true)
1250
1555
  }
1251
1556
 
1252
1557
  // MARK: - Authentication Sequence
@@ -1327,9 +1632,9 @@ class G2: NSObject, SGCManager {
1327
1632
  0x4A, 0x0A, // field 9, length 10
1328
1633
  0x08, 0x00, // unitFormat=0
1329
1634
  0x10, 0x00, // distanceUnit=0
1330
- 0x18, 0x01, // timeFormat=1
1635
+ 0x18, UInt8(self.dashboardHalfDayFormat()), // timeFormat / halfDayFormat
1331
1636
  0x20, 0x00, // dateFormat=0
1332
- 0x28, 0x01, // temperatureUnit=1
1637
+ 0x28, UInt8(self.dashboardTemperatureUnit()), // temperatureUnit
1333
1638
  ])
1334
1639
  )
1335
1640
  self.sendG2SettingCommand(univW.data)
@@ -1352,45 +1657,48 @@ class G2: NSObject, SGCManager {
1352
1657
  )
1353
1658
 
1354
1659
  // 3. teleprompter (0x10) — config (cmd=1, field3={1:4})
1355
- var teleW = ProtobufWriter()
1356
- teleW.writeInt32Field(1, 1)
1357
- teleW.writeInt32Field(2, self.sendManager.nextMagicRandom())
1358
- teleW.writeMessageField(3, Data([0x08, 0x04])) // {1:4}
1359
- self.sendToGlasses(
1360
- self.sendManager.buildPackets(
1361
- serviceId: 0x10, payload: teleW.data, reserveFlag: true
1362
- )
1363
- )
1364
-
1365
- // 4. EvenHub CTRL on service 0x81 (cmd=1, empty field3)
1366
- var ehCtrlW = ProtobufWriter()
1367
- ehCtrlW.writeInt32Field(1, 1)
1368
- ehCtrlW.writeInt32Field(2, self.sendManager.nextMagicRandom())
1369
- ehCtrlW.writeMessageField(3, Data())
1370
- self.sendEvenHubCtrlCommand(ehCtrlW.data)
1371
-
1372
- // 5. calendar (0x04) — config
1373
- var calW = ProtobufWriter()
1374
- calW.writeInt32Field(1, 1)
1375
- calW.writeInt32Field(2, self.sendManager.nextMagicRandom())
1376
- calW.writeMessageField(
1377
- 3, Data([0x08, 0x01, 0x10, 0x01, 0x18, 0x05, 0x28, 0x01])
1378
- )
1379
- self.sendToGlasses(
1380
- self.sendManager.buildPackets(
1381
- serviceId: 0x04, payload: calW.data, reserveFlag: true
1382
- )
1383
- )
1660
+ // var teleW = ProtobufWriter()
1661
+ // teleW.writeInt32Field(1, 1)
1662
+ // teleW.writeInt32Field(2, self.sendManager.nextMagicRandom())
1663
+ // teleW.writeMessageField(3, Data([0x08, 0x04])) // {1:4}
1664
+ // self.sendToGlasses(
1665
+ // self.sendManager.buildPackets(
1666
+ // serviceId: 0x10, payload: teleW.data, reserveFlag: true
1667
+ // )
1668
+ // )
1669
+
1670
+ // // 4. EvenHub CTRL on service 0x81 (cmd=1, empty field3)
1671
+ // var ehCtrlW = ProtobufWriter()
1672
+ // ehCtrlW.writeInt32Field(1, 1)
1673
+ // ehCtrlW.writeInt32Field(2, self.sendManager.nextMagicRandom())
1674
+ // ehCtrlW.writeMessageField(3, Data())
1675
+ // self.sendEvenHubCtrlCommand(ehCtrlW.data)
1676
+
1677
+ // // 5. calendar (0x04) — config
1678
+ // var calW = ProtobufWriter()
1679
+ // calW.writeInt32Field(1, 1)
1680
+ // calW.writeInt32Field(2, self.sendManager.nextMagicRandom())
1681
+ // calW.writeMessageField(
1682
+ // 3, Data([0x08, 0x01, 0x10, 0x01, 0x18, 0x05, 0x28, 0x01])
1683
+ // )
1684
+ // self.sendToGlasses(
1685
+ // self.sendManager.buildPackets(
1686
+ // serviceId: 0x04, payload: calW.data, reserveFlag: true
1687
+ // )
1688
+ // )
1384
1689
 
1385
1690
  // 6. Dashboard init (0x01) — display settings
1691
+ // halfDayFormat: 1 = 12h, 0 = 24h
1692
+ // temperatureUnit: 1 = Celsius (metric), 2 = Fahrenheit (imperial)
1386
1693
  var dashDisplayW = ProtobufWriter()
1387
1694
  dashDisplayW.writeInt32Field(1, 4) // displayMode
1388
1695
  dashDisplayW.writeInt32Field(2, 3) // statusDisplayCount
1389
1696
  dashDisplayW.writeMessageField(3, Data([1, 2, 3])) // statusDisplayOrder
1390
1697
  dashDisplayW.writeInt32Field(4, 4) // widgetDisplayCount
1391
- dashDisplayW.writeMessageField(5, Data([1, 3, 2, 2])) // widgetDisplayOrder
1392
- dashDisplayW.writeInt32Field(6, 1) // halfDayFormat
1393
- dashDisplayW.writeInt32Field(7, 1) // temperatureUnit
1698
+ // WidgetType: 1=News, 2=Stock, 3=Schedule, 4=Quicklist, 5=Health
1699
+ dashDisplayW.writeMessageField(5, Data([3, 1, 2, 4, 5])) // widgetDisplayOrder: Schedule, News, Stock, Quicklist
1700
+ dashDisplayW.writeInt32Field(6, self.dashboardHalfDayFormat()) // halfDayFormat
1701
+ dashDisplayW.writeInt32Field(7, self.dashboardTemperatureUnit()) // temperatureUnit
1394
1702
 
1395
1703
  var dashRecvW = ProtobufWriter()
1396
1704
  dashRecvW.writeMessageField(2, dashDisplayW.data)
@@ -1402,32 +1710,32 @@ class G2: NSObject, SGCManager {
1402
1710
  self.sendDashboardCommand(dashPkgW.data)
1403
1711
 
1404
1712
  // 7. Dashboard REQUEST_NEWS_INFO (cmd=5, field7={1:1})
1405
- var dashNewsReqW = ProtobufWriter()
1406
- dashNewsReqW.writeInt32Field(1, 5) // REQUEST_NEWS_INFO
1407
- dashNewsReqW.writeInt32Field(2, self.sendManager.nextMagicRandom())
1408
- dashNewsReqW.writeMessageField(7, Data([0x08, 0x01])) // {1:1}
1409
- self.sendDashboardCommand(dashNewsReqW.data)
1410
-
1411
- // 8. Gesture control list via g2_setting
1412
- var gestListW = ProtobufWriter()
1413
- gestListW.writeInt32Field(1, 1) // DeviceReceiveInfo
1414
- gestListW.writeInt32Field(2, self.sendManager.nextMagicRandom())
1415
- // field 3 with field 10 (gestureControlList): 3 items, all app_unable
1416
- let gestureCtrlPayload = Data([
1417
- 0x52, 0x18, // field 10, length 24
1418
- 0x0A, 0x06, 0x08, 0x00, 0x10, 0x00, 0x18, 0x00, // item 1
1419
- 0x0A, 0x06, 0x08, 0x00, 0x10, 0x01, 0x18, 0x00, // item 2
1420
- 0x0A, 0x06, 0x08, 0x00, 0x10, 0x02, 0x18, 0x00, // item 3
1421
- ])
1422
- gestListW.writeMessageField(3, gestureCtrlPayload)
1423
- self.sendG2SettingCommand(gestListW.data)
1424
-
1425
- // 9. Dashboard APP_REQUEST_NEWS_INFO (cmd=7, field9={1:1})
1426
- var dashAppNewsW = ProtobufWriter()
1427
- dashAppNewsW.writeInt32Field(1, 7) // APP_REQUEST_NEWS_INFO
1428
- dashAppNewsW.writeInt32Field(2, self.sendManager.nextMagicRandom())
1429
- dashAppNewsW.writeMessageField(9, Data([0x08, 0x01])) // {1:1}
1430
- self.sendDashboardCommand(dashAppNewsW.data)
1713
+ // var dashNewsReqW = ProtobufWriter()
1714
+ // dashNewsReqW.writeInt32Field(1, 5) // REQUEST_NEWS_INFO
1715
+ // dashNewsReqW.writeInt32Field(2, self.sendManager.nextMagicRandom())
1716
+ // dashNewsReqW.writeMessageField(7, Data([0x08, 0x01])) // {1:1}
1717
+ // self.sendDashboardCommand(dashNewsReqW.data)
1718
+
1719
+ // // 8. Gesture control list via g2_setting
1720
+ // var gestListW = ProtobufWriter()
1721
+ // gestListW.writeInt32Field(1, 1) // DeviceReceiveInfo
1722
+ // gestListW.writeInt32Field(2, self.sendManager.nextMagicRandom())
1723
+ // // field 3 with field 10 (gestureControlList): 3 items, all app_unable
1724
+ // let gestureCtrlPayload = Data([
1725
+ // 0x52, 0x18, // field 10, length 24
1726
+ // 0x0A, 0x06, 0x08, 0x00, 0x10, 0x00, 0x18, 0x00, // item 1
1727
+ // 0x0A, 0x06, 0x08, 0x00, 0x10, 0x01, 0x18, 0x00, // item 2
1728
+ // 0x0A, 0x06, 0x08, 0x00, 0x10, 0x02, 0x18, 0x00, // item 3
1729
+ // ])
1730
+ // gestListW.writeMessageField(3, gestureCtrlPayload)
1731
+ // self.sendG2SettingCommand(gestListW.data)
1732
+
1733
+ // // 9. Dashboard APP_REQUEST_NEWS_INFO (cmd=7, field9={1:1})
1734
+ // var dashAppNewsW = ProtobufWriter()
1735
+ // dashAppNewsW.writeInt32Field(1, 7) // APP_REQUEST_NEWS_INFO
1736
+ // dashAppNewsW.writeInt32Field(2, self.sendManager.nextMagicRandom())
1737
+ // dashAppNewsW.writeMessageField(9, Data([0x08, 0x01])) // {1:1}
1738
+ // self.sendDashboardCommand(dashAppNewsW.data)
1431
1739
 
1432
1740
  Bridge.log("G2: Sent full Even-compatible init sequence")
1433
1741
  }
@@ -1464,57 +1772,15 @@ class G2: NSObject, SGCManager {
1464
1772
 
1465
1773
  // send dashboard menu if we have stored items
1466
1774
  self.sendMenuApps()
1775
+
1776
+ // send calendar events
1777
+ let calendarEvents = DeviceStore.shared.get("bluetooth", "calendar_events") as? [[String: Any]] ?? []
1778
+ self.sendCalendarEvents(calendarEvents)
1467
1779
  }
1468
1780
  }
1469
1781
  }
1470
1782
  }
1471
1783
 
1472
- private func runDashboardSequence() {
1473
- Bridge.log("G2: Running dashboard sequence")
1474
-
1475
- // send the shutdown command to the glasses:
1476
- let msg = EvenHubProto.shutdownMessage()
1477
- sendEvenHubCommand(msg)
1478
- pageCreated = false
1479
- currentTextContent = ""
1480
-
1481
- // // Auth to left side
1482
- // if leftPeripheral != nil && leftWriteChar != nil {
1483
- // let authL = DevSettingsProto.authCmd(magicRandom: sendManager.nextMagicRandom())
1484
- // sendDevSettingsCommand(authL, left: true, right: false)
1485
- // }
1486
-
1487
- // // Small delay then auth right + pipe role change + time sync
1488
- DispatchQueue.main.asyncAfter(deadline: .now() + 1) { [weak self] in
1489
- guard let self = self else { return }
1490
- // 1. gesture_ctrl init (field1=0, field2=magicRandom)
1491
- var gestureInitW = ProtobufWriter()
1492
- gestureInitW.writeInt32Field(1, 0)
1493
- gestureInitW.writeInt32Field(2, self.sendManager.nextMagicRandom())
1494
- self.sendGestureCtrlCommand(gestureInitW.data)
1495
-
1496
- // 6. Dashboard init (0x01) — display settings
1497
- var dashDisplayW = ProtobufWriter()
1498
- dashDisplayW.writeInt32Field(1, 4) // displayMode
1499
- dashDisplayW.writeInt32Field(2, 3) // statusDisplayCount
1500
- dashDisplayW.writeMessageField(3, Data([1, 2, 3])) // statusDisplayOrder
1501
- dashDisplayW.writeInt32Field(4, 4) // widgetDisplayCount
1502
- dashDisplayW.writeMessageField(5, Data([1, 3, 2, 2])) // widgetDisplayOrder
1503
- dashDisplayW.writeInt32Field(6, 1) // halfDayFormat
1504
- dashDisplayW.writeInt32Field(7, 1) // temperatureUnit
1505
-
1506
- var dashRecvW = ProtobufWriter()
1507
- dashRecvW.writeMessageField(2, dashDisplayW.data)
1508
-
1509
- var dashPkgW = ProtobufWriter()
1510
- dashPkgW.writeInt32Field(1, 2) // Dashboard_Receive
1511
- dashPkgW.writeInt32Field(2, self.sendManager.nextMagicRandom())
1512
- dashPkgW.writeMessageField(4, dashRecvW.data)
1513
- self.sendDashboardCommand(dashPkgW.data)
1514
- Bridge.log("G2: Sent full Even-compatible init sequence")
1515
- }
1516
- }
1517
-
1518
1784
  // MARK: - Heartbeats
1519
1785
 
1520
1786
  private func startHeartbeats() {
@@ -1679,140 +1945,90 @@ class G2: NSObject, SGCManager {
1679
1945
  return true
1680
1946
  }
1681
1947
 
1682
- func displayBitmapLoc(rawData: Data, x: Int32, y: Int32, id: Int32) async -> Bool {
1683
- Bridge.log("G2: displayBitmap() - decoded \(rawData.count) bytes from base64")
1948
+ /// Display a bitmap inside a positioned image container.
1949
+ ///
1950
+ /// The page keeps a live list of up to 4 image containers keyed by exact rect:
1951
+ /// - If a container with the requested rect already exists, the image is just resent to it
1952
+ /// (no page rebuild).
1953
+ /// - Otherwise a new container is added (evicting the oldest when the list would exceed 4) and
1954
+ /// the page is rebuilt before the image is sent.
1955
+ ///
1956
+ /// Omitted params default to a 100x100 container in the top-left corner.
1957
+ func displayBitmap(base64ImageData: String, x: Int32? = nil, y: Int32? = nil, width: Int32? = nil, height: Int32? = nil) async -> Bool {
1958
+ currentBitmapBase64 = base64ImageData
1959
+ currentTextContent = ""
1684
1960
 
1685
- Bridge.log(
1686
- "G2: displayBitmap() - state: startupPageCreated=\(startupPageCreated), pageCreated=\(pageCreated)"
1687
- )
1961
+ let rx = x ?? G2.defaultImgContainer.x
1962
+ let ry = y ?? G2.defaultImgContainer.y
1963
+ let rw = width ?? G2.defaultImgContainer.width
1964
+ let rh = height ?? G2.defaultImgContainer.height
1688
1965
 
1689
- // --- Single-tile approach: scale source to fit 200x100, send as one image container ---
1690
- guard let bmpData = convertToG2Bmp(rawData, containerWidth: 200, containerHeight: 100)
1691
- else {
1692
- Bridge.log("G2: displayBitmap() - failed to convert image to BMP")
1966
+ guard let rawData = Data(base64Encoded: base64ImageData) else {
1967
+ Bridge.log("G2: displayBitmap() - failed to decode base64")
1693
1968
  return false
1694
1969
  }
1695
1970
 
1696
- // Center the 200x100 container on the 576x288 canvas
1697
- let containerW: Int32 = 200
1698
- let containerH: Int32 = 100
1699
- let containerX: Int32 = x
1700
- let containerY: Int32 = y
1701
- let containerID: Int32 = id
1702
- let containerName = "img-\(id)"
1703
-
1704
- let imageContainer = EvenHubProto.imageContainerProperty(
1705
- x: containerX, y: containerY,
1706
- width: containerW, height: containerH,
1707
- containerID: containerID, containerName: containerName
1708
- )
1709
-
1710
- let msg: Data
1971
+ // Create the startup page lazily, seeded with the default top-left container.
1711
1972
  if !startupPageCreated {
1712
- Bridge.log("G2: displayBitmap() - creating startup page with image container")
1713
- msg = EvenHubProto.createPageMessage(
1714
- imageContainers: [imageContainer], magicRandom: sendManager.nextMagicRandom(),
1715
- appId: activeMenuAppId
1716
- )
1717
- startupPageCreated = true
1973
+ imageContainers = [
1974
+ ImgContainer(
1975
+ id: imageContainerIDPool[0],
1976
+ x: G2.defaultImgContainer.x, y: G2.defaultImgContainer.y,
1977
+ width: G2.defaultImgContainer.width, height: G2.defaultImgContainer.height
1978
+ )
1979
+ ]
1980
+ createPageWithText("")
1981
+ Bridge.log("G2: displayBitmap() - startup page created, waiting 1s before sending image data...")
1982
+ try? await Task.sleep(nanoseconds: 1_000_000_000)
1983
+ }
1984
+
1985
+ // Reuse an existing container if the rect matches exactly; otherwise add a new one.
1986
+ let container: ImgContainer
1987
+ if let existing = imageContainers.first(where: { $0.matches(x: rx, y: ry, width: rw, height: rh) }) {
1988
+ container = existing
1989
+ Bridge.log("G2: displayBitmap() - reusing container \(existing.id) for rect \(rx),\(ry) \(rw)x\(rh)")
1718
1990
  } else {
1719
- Bridge.log("G2: displayBitmap() - rebuilding page with image container")
1720
- msg = EvenHubProto.rebuildPageMessage(
1721
- imageContainers: [imageContainer], magicRandom: sendManager.nextMagicRandom(),
1722
- appId: activeMenuAppId
1723
- )
1991
+ container = addImageContainer(x: rx, y: ry, width: rw, height: rh)
1992
+ Bridge.log("G2: displayBitmap() - added container \(container.id) for rect \(rx),\(ry) \(rw)x\(rh), rebuilding page")
1993
+ rebuildPage()
1994
+ try? await Task.sleep(nanoseconds: 1_000_000_000) // settle before sending image data
1995
+ }
1996
+
1997
+ guard let bmpData = convertToG2Bmp(rawData, containerWidth: Int(container.width), containerHeight: Int(container.height))
1998
+ else {
1999
+ Bridge.log("G2: displayBitmap() - failed to convert image to BMP")
2000
+ return false
1724
2001
  }
1725
- sendEvenHubCommand(msg)
1726
- pageCreated = true
1727
- pageHasTextContainer = false
1728
- currentTextContent = ""
1729
- Bridge.log("G2: displayBitmap() - page sent, waiting 1s before sending fragments...")
1730
- try? await Task.sleep(nanoseconds: 1_000_000_000) // 1s - give glasses time to process page
1731
2002
 
1732
- // Send the BMP data
1733
2003
  let success = await sendImageData(
1734
- containerID: containerID, containerName: containerName, bmpData: bmpData
2004
+ containerID: container.id, containerName: container.name, bmpData: bmpData
1735
2005
  )
1736
2006
  if !success {
1737
2007
  Bridge.log("G2: displayBitmap() - failed sending image data")
1738
2008
  }
1739
-
1740
- Bridge.log("G2: displayBitmap() - single tile sent, \(bmpData.count) bytes")
2009
+ Bridge.log("G2: displayBitmap() - image sent to container \(container.id), \(bmpData.count) bytes")
1741
2010
  return success
1742
2011
  }
1743
2012
 
1744
- func displayBitmapQuad(base64ImageData: String) async -> Bool {
1745
- guard let rawData = Data(base64Encoded: base64ImageData) else {
1746
- Bridge.log("G2: displayBitmapQuad() - failed to decode base64")
1747
- return false
1748
- }
1749
-
1750
- guard let tiles = renderAndSliceTo4Tiles(rawData) else {
1751
- Bridge.log("G2: displayBitmapQuad() - failed to slice image into tiles")
1752
- return false
2013
+ /// Add a new image container for `rect`, evicting the oldest when the list is full (max 4).
2014
+ /// Returns the newly tracked container (with an assigned ID from the pool).
2015
+ private func addImageContainer(x: Int32, y: Int32, width: Int32, height: Int32) -> ImgContainer {
2016
+ // Evict the oldest container when at capacity, freeing its ID for reuse.
2017
+ if imageContainers.count >= imageContainerIDPool.count {
2018
+ let evicted = imageContainers.removeFirst()
2019
+ Bridge.log("G2: evicting oldest image container \(evicted.id)")
1753
2020
  }
1754
-
1755
- // 2x2 grid of 200x100 tiles covering 400x200
1756
- let container1 = EvenHubProto.imageContainerProperty(
1757
- x: 0, y: 0, width: 200, height: 100,
1758
- containerID: 10, containerName: "img-10"
1759
- )
1760
- let container2 = EvenHubProto.imageContainerProperty(
1761
- x: 200, y: 0, width: 200, height: 100,
1762
- containerID: 11, containerName: "img-11"
1763
- )
1764
- let container3 = EvenHubProto.imageContainerProperty(
1765
- x: 0, y: 100, width: 200, height: 100,
1766
- containerID: 12, containerName: "img-12"
1767
- )
1768
- let container4 = EvenHubProto.imageContainerProperty(
1769
- x: 200, y: 100, width: 200, height: 100,
1770
- containerID: 13, containerName: "img-13"
1771
- )
1772
-
1773
- let msg: Data
1774
- if !startupPageCreated {
1775
- msg = EvenHubProto.createPageMessage(
1776
- imageContainers: [
1777
- container1, container2, container3, container4,
1778
- ], magicRandom: sendManager.nextMagicRandom(), appId: activeMenuAppId
1779
- )
1780
- startupPageCreated = true
1781
- } else {
1782
- msg = EvenHubProto.rebuildPageMessage(
1783
- imageContainers: [
1784
- container1, container2, container3, container4,
1785
- ], magicRandom: sendManager.nextMagicRandom(), appId: activeMenuAppId
1786
- )
1787
- }
1788
- sendEvenHubCommand(msg)
1789
- pageCreated = true
1790
- pageHasTextContainer = false
1791
- currentTextContent = ""
1792
-
1793
- try? await Task.sleep(nanoseconds: 1_000_000_000)
1794
-
1795
- // Send each tile's unique BMP data to its container
1796
- let success1 = await sendImageData(
1797
- containerID: 10, containerName: "img-10", bmpData: tiles[0]
1798
- )
1799
- let success2 = await sendImageData(
1800
- containerID: 11, containerName: "img-11", bmpData: tiles[1]
1801
- )
1802
- let success3 = await sendImageData(
1803
- containerID: 12, containerName: "img-12", bmpData: tiles[2]
1804
- )
1805
- let success4 = await sendImageData(
1806
- containerID: 13, containerName: "img-13", bmpData: tiles[3]
1807
- )
1808
-
1809
- return success1 && success2 && success3 && success4
2021
+ // Pick the lowest free ID from the pool.
2022
+ let usedIDs = Set(imageContainers.map { $0.id })
2023
+ let id = imageContainerIDPool.first { !usedIDs.contains($0) } ?? imageContainerIDPool[0]
2024
+ let container = ImgContainer(id: id, x: x, y: y, width: width, height: height)
2025
+ imageContainers.append(container)
2026
+ return container
1810
2027
  }
1811
2028
 
1812
- func displayBitmap(base64ImageData: String) async -> Bool {
1813
- currentBitmapBase64 = base64ImageData
1814
- currentTextContent = ""
1815
- return await displayBitmapQuad(base64ImageData: base64ImageData)
2029
+ /// Rebuild the page from the current text + image container list.
2030
+ private func rebuildPage() {
2031
+ createPageWithText(currentTextContent)
1816
2032
  }
1817
2033
 
1818
2034
  /// Upscale BMP pixel data by 2x (200x100 → 400x200) using nearest-neighbor
@@ -1897,73 +2113,6 @@ class G2: NSObject, SGCManager {
1897
2113
  return dst
1898
2114
  }
1899
2115
 
1900
- func displayBitmapOriginal(base64ImageData: String) async -> Bool {
1901
- guard let rawData = Data(base64Encoded: base64ImageData) else {
1902
- Bridge.log("G2: displayBitmap() - failed to decode base64")
1903
- return false
1904
- }
1905
-
1906
- Bridge.log("G2: displayBitmap() - decoded \(rawData.count) bytes from base64")
1907
-
1908
- Bridge.log(
1909
- "G2: displayBitmap() - state: startupPageCreated=\(startupPageCreated), pageCreated=\(pageCreated)"
1910
- )
1911
-
1912
- // --- Single-tile approach: scale source to fit 200x100, send as one image container ---
1913
- guard let bmpData = convertToG2Bmp(rawData, containerWidth: 200, containerHeight: 100)
1914
- else {
1915
- Bridge.log("G2: displayBitmap() - failed to convert image to BMP")
1916
- return false
1917
- }
1918
-
1919
- // Center the 200x100 container on the 576x288 canvas
1920
- let containerW: Int32 = 200
1921
- let containerH: Int32 = 100
1922
- let containerX: Int32 = (576 - containerW) / 2
1923
- let containerY: Int32 = (288 - containerH) / 2
1924
- let containerID: Int32 = 10
1925
- let containerName = "img-single"
1926
-
1927
- let imageContainer = EvenHubProto.imageContainerProperty(
1928
- x: containerX, y: containerY,
1929
- width: containerW, height: containerH,
1930
- containerID: containerID, containerName: containerName
1931
- )
1932
-
1933
- let msg: Data
1934
- if !startupPageCreated {
1935
- Bridge.log("G2: displayBitmap() - creating startup page with image container")
1936
- msg = EvenHubProto.createPageMessage(
1937
- imageContainers: [imageContainer], magicRandom: sendManager.nextMagicRandom(),
1938
- appId: activeMenuAppId
1939
- )
1940
- startupPageCreated = true
1941
- } else {
1942
- Bridge.log("G2: displayBitmap() - rebuilding page with image container")
1943
- msg = EvenHubProto.rebuildPageMessage(
1944
- imageContainers: [imageContainer], magicRandom: sendManager.nextMagicRandom(),
1945
- appId: activeMenuAppId
1946
- )
1947
- }
1948
- sendEvenHubCommand(msg)
1949
- pageCreated = true
1950
- pageHasTextContainer = false
1951
- currentTextContent = ""
1952
- Bridge.log("G2: displayBitmap() - page sent, waiting 1s before sending fragments...")
1953
- try? await Task.sleep(nanoseconds: 1_000_000_000) // 1s - give glasses time to process page
1954
-
1955
- // Send the BMP data
1956
- let success = await sendImageData(
1957
- containerID: containerID, containerName: containerName, bmpData: bmpData
1958
- )
1959
- if !success {
1960
- Bridge.log("G2: displayBitmap() - failed sending image data")
1961
- }
1962
-
1963
- Bridge.log("G2: displayBitmap() - single tile sent, \(bmpData.count) bytes")
1964
- return success
1965
- }
1966
-
1967
2116
  // MARK: - Bitmap Conversion
1968
2117
 
1969
2118
  /// Scale source image to fit within containerWidth x containerHeight (maintaining aspect ratio),
@@ -2031,93 +2180,6 @@ class G2: NSObject, SGCManager {
2031
2180
  return bmp
2032
2181
  }
2033
2182
 
2034
- // MARK: - Bitmap Conversion (4-tile approach for G2 - kept for future use)
2035
-
2036
- private static let tileWidth = 200
2037
- private static let tileHeight = 100
2038
- // Total image area: 400x200 (2x2 grid of 200x100 tiles)
2039
-
2040
- /// Render any image to 400x200 grayscale, then slice into 4 tiles (200x100 each).
2041
- /// Returns 4 BMP Data objects: [top-left, top-right, bottom-left, bottom-right].
2042
- private func renderAndSliceTo4Tiles(_ data: Data) -> [Data]? {
2043
- guard let image = UIImage(data: data), let cgImage = image.cgImage else {
2044
- Bridge.log("G2: renderAndSliceTo4Tiles - could not decode image")
2045
- return nil
2046
- }
2047
-
2048
- let srcWidth = cgImage.width
2049
- let srcHeight = cgImage.height
2050
- let totalW = G2.tileWidth * 2 // 400
2051
- let totalH = G2.tileHeight * 2 // 200
2052
-
2053
- // Scale source to fit within 400x200 (maintain aspect ratio)
2054
- let scale = min(Double(totalW) / Double(srcWidth), Double(totalH) / Double(srcHeight))
2055
- let scaledW = Int(Double(srcWidth) * scale)
2056
- let scaledH = Int(Double(srcHeight) * scale)
2057
- let offsetX = (totalW - scaledW) / 2
2058
- let offsetY = (totalH - scaledH) / 2
2059
-
2060
- Bridge.log(
2061
- "G2: renderAndSliceTo4Tiles - input \(srcWidth)x\(srcHeight) → \(scaledW)x\(scaledH) in \(totalW)x\(totalH)"
2062
- )
2063
-
2064
- // Render to 400x200 8-bit grayscale
2065
- guard
2066
- let ctx = CGContext(
2067
- data: nil,
2068
- width: totalW,
2069
- height: totalH,
2070
- bitsPerComponent: 8,
2071
- bytesPerRow: totalW,
2072
- space: CGColorSpaceCreateDeviceGray(),
2073
- bitmapInfo: CGImageAlphaInfo.none.rawValue
2074
- )
2075
- else {
2076
- Bridge.log("G2: renderAndSliceTo4Tiles - failed to create CGContext")
2077
- return nil
2078
- }
2079
-
2080
- ctx.setFillColor(gray: 0, alpha: 1)
2081
- ctx.fill(CGRect(x: 0, y: 0, width: totalW, height: totalH))
2082
- ctx.interpolationQuality = .high
2083
- ctx.draw(cgImage, in: CGRect(x: offsetX, y: offsetY, width: scaledW, height: scaledH))
2084
-
2085
- guard let renderedImage = ctx.makeImage(),
2086
- let fullPixels = renderedImage.dataProvider?.data as Data?
2087
- else {
2088
- Bridge.log("G2: renderAndSliceTo4Tiles - failed to get pixel data")
2089
- return nil
2090
- }
2091
-
2092
- // Slice into 4 tiles and build BMP for each
2093
- // CGContext origin is bottom-left, but pixel data is top-left row-first
2094
- let tw = G2.tileWidth // 200
2095
- let th = G2.tileHeight // 100
2096
- let tileOrigins = [
2097
- (0, 0), // top-left
2098
- (tw, 0), // top-right
2099
- (0, th), // bottom-left
2100
- (tw, th), // bottom-right
2101
- ]
2102
-
2103
- var tiles: [Data] = []
2104
- for (ox, oy) in tileOrigins {
2105
- // Extract tile pixels from the full 400x200 buffer
2106
- var tilePixels = Data(capacity: tw * th)
2107
- for row in 0 ..< th {
2108
- let srcRowStart = (oy + row) * totalW + ox
2109
- tilePixels.append(fullPixels[srcRowStart ..< (srcRowStart + tw)])
2110
- }
2111
- guard let bmp = build4BitBmp(grayscalePixels: tilePixels, width: tw, height: th) else {
2112
- Bridge.log("G2: renderAndSliceTo4Tiles - failed to build BMP for tile")
2113
- return nil
2114
- }
2115
- tiles.append(bmp)
2116
- }
2117
-
2118
- return tiles
2119
- }
2120
-
2121
2183
  /// Build a 4-bit indexed BMP file from 8-bit grayscale pixel data.
2122
2184
  /// BMP rows are stored bottom-up. Each row is padded to a 4-byte boundary.
2123
2185
  private func build4BitBmp(grayscalePixels: Data, width: Int, height: Int) -> Data? {
@@ -2196,7 +2258,7 @@ class G2: NSObject, SGCManager {
2196
2258
  /// the foreground by tearing down whatever EvenHub page we currently own.
2197
2259
  /// The glasses fall back to the dashboard automatically when no page is up.
2198
2260
  func showDashboard() {
2199
- Bridge.log("G2: showDashboard")
2261
+ Bridge.log("G2: showDashboard()")
2200
2262
  dashboardShowing += 2
2201
2263
  let msg = EvenHubProto.shutdownMessage()
2202
2264
  sendEvenHubCommand(msg)
@@ -2212,6 +2274,114 @@ class G2: NSObject, SGCManager {
2212
2274
  }
2213
2275
  }
2214
2276
 
2277
+ private func dashboardHalfDayFormat() -> Int32 {
2278
+ let twelveHour = DeviceStore.shared.get("bluetooth", "twelve_hour_time") as? Bool ?? true
2279
+ return twelveHour ? 1 : 0
2280
+ }
2281
+
2282
+ private func dashboardTemperatureUnit() -> Int32 {
2283
+ let metric = DeviceStore.shared.get("bluetooth", "metric_system") as? Bool ?? false
2284
+ return metric ? 1 : 2
2285
+ }
2286
+
2287
+ func sendDashboardDisplaySettings() {
2288
+ var dashDisplayW = ProtobufWriter()
2289
+ dashDisplayW.writeInt32Field(1, 4) // displayMode
2290
+ dashDisplayW.writeInt32Field(2, 3) // statusDisplayCount
2291
+ dashDisplayW.writeMessageField(3, Data([1, 2, 3])) // statusDisplayOrder
2292
+ dashDisplayW.writeInt32Field(4, 4) // widgetDisplayCount
2293
+ // WidgetType: 1=News, 2=Stock, 3=Schedule, 4=Quicklist, 5=Health
2294
+ dashDisplayW.writeMessageField(5, Data([3, 1, 2, 4, 5]))
2295
+ dashDisplayW.writeInt32Field(6, dashboardHalfDayFormat()) // halfDayFormat
2296
+ dashDisplayW.writeInt32Field(7, dashboardTemperatureUnit()) // temperatureUnit
2297
+
2298
+ var dashRecvW = ProtobufWriter()
2299
+ dashRecvW.writeMessageField(2, dashDisplayW.data)
2300
+
2301
+ var dashPkgW = ProtobufWriter()
2302
+ dashPkgW.writeInt32Field(1, 2) // Dashboard_Receive
2303
+ dashPkgW.writeInt32Field(2, sendManager.nextMagicRandom())
2304
+ dashPkgW.writeMessageField(4, dashRecvW.data)
2305
+ sendDashboardCommand(dashPkgW.data)
2306
+ }
2307
+
2308
+ /// Push a calendar event to the dashboard's Schedule widget (service 0x01).
2309
+ ///
2310
+ /// - title: event title displayed on the widget
2311
+ /// - location: optional location string
2312
+ /// - time: pre-formatted display string (e.g. "10:00 AM" or "10:00 – 10:30").
2313
+ /// The widget shows this verbatim — format it however you want it to read.
2314
+ /// - endDate: when the event ends. Encoded the same way as the time-sync hack:
2315
+ /// Unix seconds with the local TZ offset folded in, so the glasses (which
2316
+ /// appear to treat timestamps as already-local) read it correctly.
2317
+ /// - scheduleId: stable per-event id. Reuse the same id when updating an event.
2318
+ func sendCalendarEvent(
2319
+ title: String,
2320
+ location: String? = nil,
2321
+ time: String? = nil,
2322
+ endDate: Date,
2323
+ scheduleId: Int32 = 1,
2324
+ scheduleTotal: Int32 = 1,
2325
+ scheduleNum: Int32 = 0
2326
+ ) {
2327
+ Bridge.log("G2: sendCalendarEvent(\(title), endDate=\(endDate))")
2328
+ let tzSec = Int64(TimeZone.current.secondsFromGMT())
2329
+ let endTs = Int32(truncatingIfNeeded: Int64(endDate.timeIntervalSince1970) + tzSec)
2330
+
2331
+ let payload = DashboardProto.calendarPush(
2332
+ magicRandom: sendManager.nextMagicRandom(),
2333
+ packageId: 1,
2334
+ scheduleId: scheduleId,
2335
+ title: title,
2336
+ location: location,
2337
+ time: time,
2338
+ endTimestamp: endTs,
2339
+ scheduleAuthority: 1,
2340
+ scheduleTotal: scheduleTotal,
2341
+ scheduleNum: scheduleNum
2342
+ )
2343
+ sendDashboardCommand(payload)
2344
+ }
2345
+
2346
+ /// Bridge entry for `calendar_events` store updates. Each dict is expected
2347
+ /// to match the TS `CalendarEvent` shape: { title, location?, time, endDate }
2348
+ /// where endDate is unix seconds.
2349
+ ///
2350
+ /// Sends one BLE push per event, with `scheduleTotal` set to the batch size
2351
+ /// and `scheduleNum` set to this event's 0-based slot. The widget pages
2352
+ /// through them on the glasses — without paging info the firmware overwrites
2353
+ /// slot 0 on each push and only the last event survives.
2354
+ func sendCalendarEvents(_ events: [[String: Any]]) {
2355
+ Bridge.log("G2: sendCalendarEvents — \(events.count) events")
2356
+ if events.isEmpty {
2357
+ let payload = DashboardProto.calendarClear(
2358
+ magicRandom: sendManager.nextMagicRandom(),
2359
+ packageId: 1,
2360
+ scheduleAuthority: 1
2361
+ )
2362
+ sendDashboardCommand(payload)
2363
+ return
2364
+ }
2365
+
2366
+ let total = Int32(events.count)
2367
+ for (i, ev) in events.enumerated() {
2368
+ guard let title = ev["title"] as? String,
2369
+ let time = ev["time"] as? String,
2370
+ let endTs = ev["endDate"] as? Double
2371
+ else { continue }
2372
+ let location = ev["location"] as? String
2373
+ sendCalendarEvent(
2374
+ title: title,
2375
+ location: location,
2376
+ time: time,
2377
+ endDate: Date(timeIntervalSince1970: endTs),
2378
+ scheduleId: Int32(i + 1),
2379
+ scheduleTotal: total,
2380
+ scheduleNum: Int32(i)
2381
+ )
2382
+ }
2383
+ }
2384
+
2215
2385
  func setDashboardPosition(_ height: Int, _ depth: Int) {
2216
2386
  Bridge.log("G2: setDashboardPosition(height=\(height), depth=\(depth))")
2217
2387
  setDashboardHeightOnly(height)
@@ -2259,18 +2429,30 @@ class G2: NSObject, SGCManager {
2259
2429
  content: text
2260
2430
  )
2261
2431
 
2432
+ // Build the page's image containers from the live tracked list.
2433
+ let imageContainerProps: [Data] = imageContainers.map { c in
2434
+ EvenHubProto.imageContainerProperty(
2435
+ x: c.x, y: c.y, width: c.width, height: c.height,
2436
+ containerID: c.id, containerName: c.name
2437
+ )
2438
+ }
2439
+
2262
2440
  let msg: Data
2263
2441
  if !startupPageCreated {
2264
2442
  Bridge.log("G2: createPageWithText - using createPageMessage (first time)")
2265
2443
  msg = EvenHubProto.createPageMessage(
2266
- textContainers: [tc], magicRandom: sendManager.nextMagicRandom(),
2444
+ textContainers: [tc],
2445
+ imageContainers: imageContainerProps,
2446
+ magicRandom: sendManager.nextMagicRandom(),
2267
2447
  appId: activeMenuAppId
2268
2448
  )
2269
2449
  startupPageCreated = true
2270
2450
  } else {
2271
2451
  Bridge.log("G2: createPageWithText - using rebuildPageMessage")
2272
2452
  msg = EvenHubProto.rebuildPageMessage(
2273
- textContainers: [tc], magicRandom: sendManager.nextMagicRandom(),
2453
+ textContainers: [tc],
2454
+ imageContainers: imageContainerProps,
2455
+ magicRandom: sendManager.nextMagicRandom(),
2274
2456
  appId: activeMenuAppId
2275
2457
  )
2276
2458
  }
@@ -2420,6 +2602,7 @@ class G2: NSObject, SGCManager {
2420
2602
  startupPageCreated = false
2421
2603
  pageCreated = false
2422
2604
  pageHasTextContainer = false
2605
+ dashboardShowing = 0
2423
2606
  heartbeatCounter = 0
2424
2607
  DeviceStore.shared.apply("glasses", "connected", false)
2425
2608
  DeviceStore.shared.apply("glasses", "fullyBooted", false)
@@ -2517,25 +2700,62 @@ class G2: NSObject, SGCManager {
2517
2700
  Bridge.log("G2: Sent RING_DISCONNECT_INFO for MAC \(mac)")
2518
2701
  }
2519
2702
 
2520
- func dbg1() {
2521
- Bridge.log("G2: dbg1()")
2703
+ /// Fire an EvenAI skill — the same path "Hey Even, show X" uses. Triggers a
2704
+ /// built-in glasses UI (notification list, navigation, teleprompter, etc).
2705
+ /// See `EvenAIProto.triggerSkill` for the skillId table.
2706
+ func triggerSkill(
2707
+ _ skillId: Int32, skillParam: Int32 = 0,
2708
+ text: String = "", streamEnable: Int32 = 1, fTextEnd: Int32 = 1
2709
+ ) {
2710
+ Bridge.log("G2: triggerSkill(\(skillId), skillParam=\(skillParam), text=\"\(text)\", streamEnable=\(streamEnable), fTextEnd=\(fTextEnd))")
2711
+ let payload = EvenAIProto.triggerSkill(
2712
+ magicRandom: sendManager.nextMagicRandom(),
2713
+ skillId: skillId,
2714
+ skillParam: skillParam,
2715
+ text: text,
2716
+ streamEnable: streamEnable,
2717
+ fTextEnd: fTextEnd
2718
+ )
2719
+ sendEvenAICommand(payload)
2720
+ }
2721
+
2722
+ /// Open the on-glasses notification panel — same effect as the user saying
2723
+ /// "Hey Even, show notifications". Replicates the official-app voice flow:
2724
+ /// 1. CTRL{status=ENTER} — puts glasses in AI session
2725
+ /// 2. ASK{text=" "} — minimal ASR transcript to seed session context
2726
+ /// 3. SKILL{skillId=NOTIFICATION, skillParam=show, ...} — dispatches the intent
2727
+ /// The SKILL step alone is ignored by firmware; the preceding ENTER+ASK
2728
+ /// supply the session context that lets the glasses act on the SKILL.
2729
+ func showNotificationsPanel() {
2730
+ Bridge.log("G2: showNotificationsPanel()")
2731
+ Task { @MainActor [weak self] in
2732
+ guard let self = self else { return }
2522
2733
 
2523
- // // send a shutdown message
2524
- // let msg = EvenHubProto.shutdownMessage()
2525
- // sendEvenHubCommand(msg)
2526
- // pageCreated = false
2527
- // currentTextContent = ""
2528
-
2529
- // DispatchQueue.main.asyncAfter(deadline: .now() + 1) { [weak self] in
2530
- // guard let self = self else { return }
2531
- // // self.sendShutdown()
2532
- // // runAuthSequence()
2533
- // runDashboardSequence()
2534
- // }
2734
+ let enterPayload = EvenAIProto.aiCtrl(
2735
+ magicRandom: self.sendManager.nextMagicRandom(),
2736
+ status: 2 // EVEN_AI_ENTER
2737
+ )
2738
+ self.sendEvenAICommand(enterPayload)
2739
+
2740
+ try? await Task.sleep(nanoseconds: 400_000_000)
2741
+ let askPayload = EvenAIProto.aiAsk(
2742
+ magicRandom: self.sendManager.nextMagicRandom(),
2743
+ text: " ",
2744
+ streamEnable: 0
2745
+ )
2746
+ self.sendEvenAICommand(askPayload)
2747
+
2748
+ try? await Task.sleep(nanoseconds: 400_000_000)
2749
+ self.triggerSkill(
2750
+ 3, skillParam: 1, // NOTIFICATION, show
2751
+ text: " ",
2752
+ streamEnable: 1, fTextEnd: 1
2753
+ )
2754
+ }
2755
+ }
2535
2756
 
2536
- // connectController("1B:08:26:8E:0E:E6")
2537
- // connectController()
2538
- showDashboard()
2757
+ func dbg1() {
2758
+ showNotificationsPanel()
2539
2759
  }
2540
2760
 
2541
2761
  func dbg2() {
@@ -2630,6 +2850,14 @@ class G2: NSObject, SGCManager {
2630
2850
  // TODO: Implement via dev_settings
2631
2851
  }
2632
2852
 
2853
+ /// Push the current time to the glasses. Useful after DST transitions,
2854
+ /// time-zone travel, or a long sleep where the glasses' clock has drifted.
2855
+ func syncTime() {
2856
+ Bridge.log("G2: syncTime()")
2857
+ let msg = DevSettingsProto.timeSync(magicRandom: sendManager.nextMagicRandom())
2858
+ sendDevSettingsCommand(msg, left: true, right: true)
2859
+ }
2860
+
2633
2861
  func sendRgbLedControl(
2634
2862
  requestId _: String, packageName _: String?, action _: String, color _: String?,
2635
2863
  onDurationMs _: Int, offDurationMs _: Int, count _: Int
@@ -2647,7 +2875,7 @@ class G2: NSObject, SGCManager {
2647
2875
 
2648
2876
  func requestPhoto(
2649
2877
  _: String, appId _: String, size _: String?, webhookUrl _: String?, authToken _: String?,
2650
- compress _: String?, flash _: Bool, sound _: Bool, exposureTimeNs _: Double?, iso _: Int?
2878
+ compress _: String?, flash _: Bool, save _: Bool, sound _: Bool, exposureTimeNs _: Double?, iso _: Int?
2651
2879
  ) {}
2652
2880
  func startVideoRecording(requestId _: String, save _: Bool, flash _: Bool, sound _: Bool) {}
2653
2881
  func startStream(_: [String: Any]) {}
@@ -2957,7 +3185,11 @@ class G2: NSObject, SGCManager {
2957
3185
  }
2958
3186
  if let errorCode = resFields[8] as? Int32 {
2959
3187
  // ImgResCmd has ErrorCode in field 8
2960
- Bridge.log("G2: EvenHub ImgRes errorCode=\(errorCode)")
3188
+ if errorCode == 4 {
3189
+ Bridge.log("G2: img_success")
3190
+ } else {
3191
+ Bridge.log("G2: EvenHub ImgRes errorCode=\(errorCode)")
3192
+ }
2961
3193
  }
2962
3194
  }
2963
3195
  }
@@ -3408,6 +3640,15 @@ class G2: NSObject, SGCManager {
3408
3640
  // "G2: gesture_ctrl response: \(data.map { String(format: "%02X", $0) }.joined())"
3409
3641
  // )
3410
3642
  // Bridge.log("G2: gesture_ctrl response:")
3643
+
3644
+
3645
+ // Dedup: L and R peripherals both deliver this event, so debounce or
3646
+ let timestamp = Int64(Date().timeIntervalSince1970 * 1000)
3647
+ if lastGestureCtrlTimestamp != nil && timestamp - lastGestureCtrlTimestamp! < 500 {
3648
+ Bridge.log("G2: gesture_ctrl dedup")
3649
+ return
3650
+ }
3651
+ lastGestureCtrlTimestamp = timestamp
3411
3652
 
3412
3653
  // if we got 08011A00 that means we closed the dashboard, which means the mic is probably dead,
3413
3654
  // so we need to revive it:
@@ -3648,6 +3889,7 @@ extension G2: CBCentralManagerDelegate {
3648
3889
  self.startupPageCreated = false
3649
3890
  self.pageCreated = false
3650
3891
  self.pageHasTextContainer = false
3892
+ self.dashboardShowing = 0
3651
3893
  DeviceStore.shared.apply("glasses", "connected", false)
3652
3894
  DeviceStore.shared.apply("glasses", "fullyBooted", false)
3653
3895