@mentra/bluetooth-sdk 0.1.9 → 0.1.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (147) hide show
  1. package/README.md +116 -18
  2. package/android/build.gradle +289 -16
  3. package/android/gradle.properties.example +9 -0
  4. package/android/lc3Lib/build.gradle +95 -6
  5. package/android/settings.gradle +3 -0
  6. package/android/silero/LICENSE.md +17 -0
  7. package/android/silero/build.gradle +42 -0
  8. package/android/silero/proguard-rules.pro +24 -0
  9. package/android/silero/src/androidTest/assets/hello.wav +0 -0
  10. package/android/silero/src/androidTest/java/com/konovalov/vad/silero/VadSileroTest.kt +79 -0
  11. package/android/silero/src/main/assets/LICENSE +21 -0
  12. package/android/silero/src/main/assets/silero_vad.onnx +0 -0
  13. package/android/silero/src/main/java/com/konovalov/vad/silero/Vad.kt +160 -0
  14. package/android/silero/src/main/java/com/konovalov/vad/silero/VadSilero.kt +449 -0
  15. package/android/silero/src/main/java/com/konovalov/vad/silero/config/FrameSize.kt +15 -0
  16. package/android/silero/src/main/java/com/konovalov/vad/silero/config/Mode.kt +14 -0
  17. package/android/silero/src/main/java/com/konovalov/vad/silero/config/SampleRate.kt +12 -0
  18. package/android/silero/src/main/java/com/konovalov/vad/silero/utils/AudioUtils.kt +47 -0
  19. package/android/silero/src/main/java/com/konovalov/vad/silero/utils/TensorMap.kt +41 -0
  20. package/android/silero/src/test/java/com/konovalov/vad/silero/utils/AudioUtilsTest.kt +44 -0
  21. package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkDefaults.kt +6 -0
  22. package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkModule.kt +195 -34
  23. package/android/src/main/java/com/mentra/bluetoothsdk/Bridge.kt +103 -21
  24. package/android/src/main/java/com/mentra/bluetoothsdk/DeviceManager.kt +210 -18
  25. package/android/src/main/java/com/mentra/bluetoothsdk/DeviceStore.kt +30 -2
  26. package/android/src/main/java/com/mentra/bluetoothsdk/MentraBluetoothSdk.kt +787 -85
  27. package/android/src/main/java/com/mentra/bluetoothsdk/audio/AudioModels.kt +6 -2
  28. package/android/src/main/java/com/mentra/bluetoothsdk/camera/CameraModels.kt +125 -11
  29. package/android/src/main/java/com/mentra/bluetoothsdk/controllers/ControllerManager.kt +12 -2
  30. package/android/src/main/java/com/mentra/bluetoothsdk/controllers/R1.kt +8 -2
  31. package/android/src/main/java/com/mentra/bluetoothsdk/debug/BleTraceLogger.kt +33 -17
  32. package/android/src/main/java/com/mentra/bluetoothsdk/events/BluetoothEvents.kt +101 -0
  33. package/android/src/main/java/com/mentra/bluetoothsdk/internal/MapParsing.kt +8 -0
  34. package/android/src/main/java/com/mentra/bluetoothsdk/photoreceiver/LocalPhotoReceiverRegistry.java +108 -0
  35. package/android/src/main/java/com/mentra/bluetoothsdk/photoreceiver/LocalPhotoUploadServer.kt +399 -74
  36. package/android/src/main/java/com/mentra/bluetoothsdk/photoreceiver/MentraPhotoReceiverModule.kt +100 -17
  37. package/android/src/main/java/com/mentra/bluetoothsdk/requests/DisplayRequests.kt +17 -0
  38. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/G1.java +2 -2
  39. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/G2.kt +1293 -758
  40. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/Mach1.java +2 -2
  41. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraLive.java +271 -66
  42. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraNex.kt +197 -149
  43. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraosBle.java +12753 -27036
  44. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/SGCManager.kt +64 -2
  45. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/Simulated.kt +16 -4
  46. package/android/src/main/java/com/mentra/bluetoothsdk/status/DeviceStatus.kt +60 -3
  47. package/android/src/main/java/com/mentra/bluetoothsdk/streaming/StreamModels.kt +12 -3
  48. package/android/src/main/java/com/mentra/bluetoothsdk/stt/STTTools.kt +87 -36
  49. package/android/src/main/java/com/mentra/bluetoothsdk/stt/SherpaOnnxTranscriber.kt +13 -4
  50. package/android/src/main/java/com/mentra/bluetoothsdk/stt/VadGateSpeechPolicy.kt +229 -0
  51. package/android/src/main/java/com/mentra/bluetoothsdk/tts/TTSTools.kt +180 -0
  52. package/android/src/main/java/com/mentra/bluetoothsdk/utils/AvifExifStripper.java +573 -0
  53. package/android/src/main/java/com/mentra/bluetoothsdk/utils/BlePhotoUploadService.java +488 -50
  54. package/android/src/main/java/com/mentra/bluetoothsdk/utils/HeifExifTagReader.java +199 -0
  55. package/android/src/main/java/com/mentra/bluetoothsdk/utils/MessageChunkReassembler.java +18 -1
  56. package/android/src/main/java/com/mentra/bluetoothsdk/utils/MessageChunker.java +64 -18
  57. package/android/src/main/java/com/mentra/bluetoothsdk/utils/NexSGCUtils.kt +48 -39
  58. package/android/src/test/java/com/mentra/bluetoothsdk/camera/PhotoRequestTest.kt +23 -0
  59. package/android/src/test/java/com/mentra/bluetoothsdk/photoreceiver/LocalPhotoReceiverRegistryTest.java +75 -0
  60. package/android/src/test/java/com/mentra/bluetoothsdk/utils/AvifExifStripperTest.java +30 -0
  61. package/android/src/test/java/com/mentra/bluetoothsdk/utils/BlePhotoUploadServiceTest.java +98 -0
  62. package/android/src/test/resources/avif_with_exif.avif +0 -0
  63. package/build/BluetoothSdk.types.d.ts +253 -44
  64. package/build/BluetoothSdk.types.d.ts.map +1 -1
  65. package/build/BluetoothSdk.types.js +11 -10
  66. package/build/BluetoothSdk.types.js.map +1 -1
  67. package/build/_private/BluetoothSdkModule.d.ts +37 -23
  68. package/build/_private/BluetoothSdkModule.d.ts.map +1 -1
  69. package/build/_private/BluetoothSdkModule.js +43 -52
  70. package/build/_private/BluetoothSdkModule.js.map +1 -1
  71. package/build/_private/photoRequestPayload.d.ts +4 -0
  72. package/build/_private/photoRequestPayload.d.ts.map +1 -0
  73. package/build/_private/photoRequestPayload.js +25 -0
  74. package/build/_private/photoRequestPayload.js.map +1 -0
  75. package/build/index.d.ts +1 -1
  76. package/build/index.d.ts.map +1 -1
  77. package/build/index.js +25 -0
  78. package/build/index.js.map +1 -1
  79. package/build/react/useMentraBluetooth.d.ts +2 -2
  80. package/build/react/useMentraBluetooth.d.ts.map +1 -1
  81. package/build/react/useMentraBluetooth.js +3 -3
  82. package/build/react/useMentraBluetooth.js.map +1 -1
  83. package/ios/BluetoothSdkModule.swift +177 -70
  84. package/ios/LocalPhotoUploadServer.swift +370 -105
  85. package/ios/MentraBluetoothSDK.podspec +3 -1
  86. package/ios/MentraPhotoReceiverModule.swift +62 -10
  87. package/ios/Packages/CoreObjC/include/PcmConverter.h +22 -0
  88. package/ios/Packages/SherpaOnnx/SherpaOnnx.swift +668 -26
  89. package/ios/Packages/SherpaOnnx/sherpa-onnx.xcframework/Info.plist +13 -9
  90. package/ios/Packages/SherpaOnnx/sherpa-onnx.xcframework/ios-arm64/Headers/sherpa-onnx/c-api/c-api.h +4551 -0
  91. package/ios/Packages/SherpaOnnx/sherpa-onnx.xcframework/ios-arm64/Headers/sherpa-onnx/c-api/cxx-api.h +1745 -0
  92. package/ios/Packages/SherpaOnnx/sherpa-onnx.xcframework/ios-arm64/libsherpa-onnx.a +0 -0
  93. package/ios/Packages/SherpaOnnx/sherpa-onnx.xcframework/ios-arm64_x86_64-simulator/Headers/sherpa-onnx/c-api/c-api.h +4551 -0
  94. package/ios/Packages/SherpaOnnx/sherpa-onnx.xcframework/ios-arm64_x86_64-simulator/Headers/sherpa-onnx/c-api/cxx-api.h +1745 -0
  95. package/ios/Packages/SherpaOnnx/sherpa-onnx.xcframework/ios-arm64_x86_64-simulator/libsherpa-onnx.a +0 -0
  96. package/ios/Source/BluetoothSdkDefaults.swift +6 -0
  97. package/ios/Source/Bridge.swift +68 -19
  98. package/ios/Source/DeviceManager.swift +167 -32
  99. package/ios/Source/DeviceStore.swift +24 -8
  100. package/ios/Source/MentraBluetoothSDK.swift +803 -47
  101. package/ios/Source/ObservableStore.swift +6 -0
  102. package/ios/Source/{Audio → audio}/AudioModels.swift +9 -7
  103. package/ios/Source/{Camera → camera}/CameraModels.swift +226 -16
  104. package/ios/Source/controllers/ControllerManager.swift +4 -3
  105. package/ios/Source/controllers/R1.swift +3 -3
  106. package/ios/Source/{Events → events}/BluetoothEvents.swift +183 -1
  107. package/ios/Source/{Internal → internal}/ValueParsing.swift +10 -1
  108. package/ios/Source/{Requests → requests}/DisplayRequests.swift +28 -4
  109. package/ios/Source/services/PhoneMic.swift +1 -0
  110. package/ios/Source/sgcs/G1.swift +5 -5
  111. package/ios/Source/sgcs/G2.swift +1494 -870
  112. package/ios/Source/sgcs/Mach1.swift +4 -4
  113. package/ios/Source/sgcs/MentraLive.swift +814 -321
  114. package/ios/Source/sgcs/MentraNex.swift +604 -244
  115. package/ios/Source/sgcs/SGCManager.swift +68 -5
  116. package/ios/Source/sgcs/Simulated.swift +9 -5
  117. package/ios/Source/sgcs/mentraos_ble.pb.swift +3073 -3134
  118. package/ios/Source/status/DeviceStatus.swift +974 -0
  119. package/ios/Source/{Status → status}/WifiHotspotStatus.swift +4 -4
  120. package/ios/Source/{Streaming → streaming}/StreamModels.swift +24 -9
  121. package/ios/Source/stt/STTTools.swift +27 -12
  122. package/ios/Source/stt/SherpaOnnxTranscriber.swift +31 -8
  123. package/ios/Source/tts/TTSTools.swift +164 -0
  124. package/ios/Source/utils/JSCExperiment.swift +7 -7
  125. package/ios/Source/utils/MessageChunkReassembler.swift +20 -1
  126. package/ios/Source/utils/MessageChunker.swift +78 -21
  127. package/ios/Source/utils/TarBz2Extractor.swift +53 -2
  128. package/package.json +1 -1
  129. package/plugin/build/withAndroid.js +68 -4
  130. package/src/BluetoothSdk.types.ts +344 -60
  131. package/src/_private/BluetoothSdkModule.ts +132 -102
  132. package/src/_private/photoRequestPayload.ts +28 -0
  133. package/src/index.ts +61 -15
  134. package/src/react/useMentraBluetooth.ts +6 -4
  135. package/android/.project +0 -28
  136. package/android/lc3Lib/.project +0 -28
  137. package/ios/Packages/SherpaOnnx/sherpa-onnx.xcframework/Headers/cargs.h +0 -162
  138. package/ios/Packages/SherpaOnnx/sherpa-onnx.xcframework/Headers/sherpa-onnx/c-api/c-api.h +0 -1852
  139. package/ios/Packages/SherpaOnnx/sherpa-onnx.xcframework/Headers/sherpa-onnx/c-api/cxx-api.h +0 -674
  140. package/ios/Packages/SherpaOnnx/sherpa-onnx.xcframework/ios-arm64/sherpa-onnx.a +0 -0
  141. package/ios/Packages/SherpaOnnx/sherpa-onnx.xcframework/ios-arm64_x86_64-simulator/sherpa-onnx.a +0 -0
  142. package/ios/Source/Status/DeviceStatus.swift +0 -471
  143. /package/ios/Source/{Connection → connection}/ScanSession.swift +0 -0
  144. /package/ios/Source/{Errors → errors}/BluetoothError.swift +0 -0
  145. /package/ios/Source/{Internal → internal}/BluetoothAvailability.swift +0 -0
  146. /package/ios/Source/{Status → status}/RuntimeState.swift +0 -0
  147. /package/ios/Source/{Types → types}/DeviceModels.swift +0 -0
@@ -13,18 +13,18 @@ import UIKit
13
13
 
14
14
  // MARK: - Data Little-Endian Helpers (for BMP construction)
15
15
 
16
- private extension Data {
17
- mutating func appendLittleEndian(_ value: UInt16) {
16
+ extension Data {
17
+ fileprivate mutating func appendLittleEndian(_ value: UInt16) {
18
18
  var v = value.littleEndian
19
19
  Swift.withUnsafeBytes(of: &v) { append(contentsOf: $0) }
20
20
  }
21
21
 
22
- mutating func appendLittleEndian(_ value: UInt32) {
22
+ fileprivate mutating func appendLittleEndian(_ value: UInt32) {
23
23
  var v = value.littleEndian
24
24
  Swift.withUnsafeBytes(of: &v) { append(contentsOf: $0) }
25
25
  }
26
26
 
27
- mutating func appendLittleEndian(_ value: Int32) {
27
+ fileprivate mutating func appendLittleEndian(_ value: Int32) {
28
28
  var v = value.littleEndian
29
29
  Swift.withUnsafeBytes(of: &v) { append(contentsOf: $0) }
30
30
  }
@@ -51,31 +51,47 @@ private enum G2BLE {
51
51
 
52
52
  /// Service IDs from service_id_def.proto
53
53
  private enum ServiceID: UInt8 {
54
- case dashboard = 1 // 0x01 - UI_BACKGROUND_DASHBOARD_APP_ID
55
- case menu = 3 // 0x03 - UI_FOREGROUND_MEUN_ID (typo is intentional — matches Even's proto)
56
- case evenAI = 7 // 0x07 - UI_FOREGROUND_EVEN_AI_ID
57
- case g2Setting = 9 // 0x09 - UI_SETTING_APP_ID
58
- case gestureCtrl = 13 // 0x0D - gesture_ctrl lifecycle signals
59
- case onboarding = 16 // 0x10 - UI_ONBOARDING_APP_ID
60
- case deviceSettings = 128 // 0x80 - UX_DEVICE_SETTINGS_APP_ID
61
- case evenHubCtrl = 129 // 0x81 - EvenHub CTRL channel (init/registration)
62
- case evenHub = 224 // 0xE0 - UI_BACKGROUND_EVENHUB_APP_ID
54
+ case dashboard = 1 // 0x01 - UI_BACKGROUND_DASHBOARD_APP_ID
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
57
+ case evenAI = 7 // 0x07 - UI_FOREGROUND_EVEN_AI_ID
58
+ case navigation = 8 // 0x08 - UI_BACKGROUND_NAVIGATION_ID (compass/heading lives here)
59
+ case g2Setting = 9 // 0x09 - UI_SETTING_APP_ID
60
+ case gestureCtrl = 13 // 0x0D - gesture_ctrl lifecycle signals
61
+ case onboarding = 16 // 0x10 - UI_ONBOARDING_APP_ID
62
+ case deviceSettings = 128 // 0x80 - UX_DEVICE_SETTINGS_APP_ID
63
+ case evenHubCtrl = 129 // 0x81 - EvenHub CTRL channel (init/registration)
64
+ case evenHub = 224 // 0xE0 - UI_BACKGROUND_EVENHUB_APP_ID
63
65
  }
64
66
 
65
67
  /// EvenHub command IDs from EvenHub.proto
66
68
  private enum EvenHubCmd: Int32 {
67
- case createStartupPage = 0 // APP_REQUEST_CREATE_STARTUP_PAGE_PACKET
68
- case updateImageRawData = 3 // APP_UPDATE_IMAGE_RAW_DATA_PACKET
69
- case updateTextData = 5 // APP_UPDATE_TEXT_DATA_PACKET
70
- case rebuildPage = 7 // APP_REQUEST_REBUILD_PAGE_PACKET
71
- case shutdownPage = 9 // APP_REQUEST_SHUTDOWN_PAGE_PACKET
72
- case heartbeat = 12 // APP_REQUEST_HEARTBEAT_PACKET
73
- case audioControl = 15 // APP_REQUEST_AUDIO_CTR_PACKET
69
+ case createStartupPage = 0 // APP_REQUEST_CREATE_STARTUP_PAGE_PACKET
70
+ case updateImageRawData = 3 // APP_UPDATE_IMAGE_RAW_DATA_PACKET
71
+ case updateTextData = 5 // APP_UPDATE_TEXT_DATA_PACKET
72
+ case rebuildPage = 7 // APP_REQUEST_REBUILD_PAGE_PACKET
73
+ case shutdownPage = 9 // APP_REQUEST_SHUTDOWN_PAGE_PACKET
74
+ case heartbeat = 12 // APP_REQUEST_HEARTBEAT_PACKET
75
+ case audioControl = 15 // APP_REQUEST_AUDIO_CTR_PACKET
76
+ case imuControl = 19 // APP_REQUEST_IMU_CTR_PACKET (confirmed via on-device brute-force)
77
+ }
78
+
79
+ /// Navigation_Cmd_list from navigation.proto (service 0x08)
80
+ private enum NavigationCmd: Int32 {
81
+ case appSendHeartbeat = 0 // APP_SEND_HEARTBEAT_CMD
82
+ case appRequestStartUp = 5 // APP_REQUEST_START_UP — begin navigation/compass session
83
+ case appSendBasicInfo = 7 // APP_SEND_BASIC_INFO
84
+ case appRequestExit = 12 // APP_REQUEST_EXIT
85
+ case osNotifyExit = 13 // OS_NOTIFY_EXIT
86
+ case osNotifyReviewChanged = 14 // OS_NOTIFY_REVIEW_CHANGED
87
+ case osNotifyCompassChanged = 15 // OS_NOTIFY_COMPASS_CHANGED — heading update
88
+ case osNotifyCompassCalibrateStart = 16 // OS_NOTIFY_COMPASS_CALIBRATE_STRAT (sic)
89
+ case osNotifyCompassCalibrateComplete = 17 // OS_NOTIFY_COMPASS_CALIBRATE_COMPLETE
74
90
  }
75
91
 
76
92
  /// EvenHub response command IDs (from glasses → phone)
77
93
  private enum EvenHubResponseCmd: Int32 {
78
- case osNotifyEventToApp = 2 // OS_NOITY_EVENT_TO_APP_PACKET - touch/gesture events
94
+ case osNotifyEventToApp = 2 // OS_NOITY_EVENT_TO_APP_PACKET - touch/gesture events
79
95
  }
80
96
 
81
97
  /// OsEventTypeList from EvenHub.proto
@@ -88,15 +104,16 @@ private enum OsEventType: Int32 {
88
104
  case foregroundExit = 5
89
105
  case abnormalExit = 6
90
106
  case systemExit = 7
107
+ case imuDataReport = 8 // IMU_DATA_REPORT — Sys_ItemEvent carries imuData
91
108
  }
92
109
 
93
110
  /// g2_settingCommandId from g2_setting.proto
94
111
  private enum G2SettingCommandId: Int32 {
95
112
  case none = 0
96
- case deviceReceiveInfo = 1 // Send settings TO glasses
97
- case deviceReceiveRequest = 2 // Request info FROM glasses
98
- case deviceSendToApp = 3 // Glasses sends info TO app
99
- case deviceRespondToApp = 4 // Glasses responds to app
113
+ case deviceReceiveInfo = 1 // Send settings TO glasses
114
+ case deviceReceiveRequest = 2 // Request info FROM glasses
115
+ case deviceSendToApp = 3 // Glasses sends info TO app
116
+ case deviceRespondToApp = 4 // Glasses responds to app
100
117
  }
101
118
 
102
119
  /// DevCfgCommandId from dev_config_protocol.proto
@@ -140,7 +157,7 @@ private struct ProtobufWriter {
140
157
  }
141
158
 
142
159
  mutating func writeInt32Field(_ fieldNumber: Int, _ value: Int32) {
143
- let tag = UInt64(fieldNumber << 3) | 0 // wire type 0 = varint
160
+ let tag = UInt64(fieldNumber << 3) | 0 // wire type 0 = varint
144
161
  writeVarint(tag)
145
162
  // protobuf int32 uses varint encoding; negative values use 10 bytes
146
163
  if value >= 0 {
@@ -150,8 +167,14 @@ private struct ProtobufWriter {
150
167
  }
151
168
  }
152
169
 
170
+ mutating func writeInt64Field(_ fieldNumber: Int, _ value: Int64) {
171
+ let tag = UInt64(fieldNumber << 3) | 0 // wire type 0 = varint
172
+ writeVarint(tag)
173
+ writeVarint(UInt64(bitPattern: value))
174
+ }
175
+
153
176
  mutating func writeStringField(_ fieldNumber: Int, _ value: String) {
154
- let tag = UInt64(fieldNumber << 3) | 2 // wire type 2 = length-delimited
177
+ let tag = UInt64(fieldNumber << 3) | 2 // wire type 2 = length-delimited
155
178
  writeVarint(tag)
156
179
  let utf8 = Array(value.utf8)
157
180
  writeVarint(UInt64(utf8.count))
@@ -159,7 +182,7 @@ private struct ProtobufWriter {
159
182
  }
160
183
 
161
184
  mutating func writeBytesField(_ fieldNumber: Int, _ value: Data) {
162
- let tag = UInt64(fieldNumber << 3) | 2 // wire type 2 = length-delimited
185
+ let tag = UInt64(fieldNumber << 3) | 2 // wire type 2 = length-delimited
163
186
  writeVarint(tag)
164
187
  writeVarint(UInt64(value.count))
165
188
  data.append(value)
@@ -221,7 +244,7 @@ private struct ProtobufReader {
221
244
  guard let len = readVarint() else { return nil }
222
245
  let length = Int(len)
223
246
  guard offset + length <= data.count else { return nil }
224
- let result = data[(data.startIndex + offset) ..< (data.startIndex + offset + length)]
247
+ let result = data[(data.startIndex + offset)..<(data.startIndex + offset + length)]
225
248
  offset += length
226
249
  return Data(result)
227
250
  }
@@ -234,10 +257,10 @@ private struct ProtobufReader {
234
257
  /// Skip a field value based on wire type
235
258
  mutating func skipField(wireType: Int) {
236
259
  switch wireType {
237
- case 0: _ = readVarint() // varint
238
- case 1: offset += 8 // 64-bit
239
- case 2: _ = readBytes() // length-delimited
240
- case 5: offset += 4 // 32-bit
260
+ case 0: _ = readVarint() // varint
261
+ case 1: offset += 8 // 64-bit
262
+ case 2: _ = readBytes() // length-delimited
263
+ case 5: offset += 4 // 32-bit
241
264
  default: break
242
265
  }
243
266
  }
@@ -249,9 +272,9 @@ private struct ProtobufReader {
249
272
  while hasMore {
250
273
  guard let (fieldNum, wireType) = readTag() else { break }
251
274
  switch wireType {
252
- case 0: // varint
275
+ case 0: // varint
253
276
  if let v = readVarint() { fields[fieldNum] = Int32(truncatingIfNeeded: v) }
254
- case 2: // length-delimited (submessage or bytes or string)
277
+ case 2: // length-delimited (submessage or bytes or string)
255
278
  if let d = readBytes() { fields[fieldNum] = d }
256
279
  default:
257
280
  skipField(wireType: wireType)
@@ -273,21 +296,21 @@ private enum EvenHubProto {
273
296
  content: String? = nil
274
297
  ) -> Data {
275
298
  var w = ProtobufWriter()
276
- w.writeInt32Field(1, x) // XPosition
277
- w.writeInt32Field(2, y) // YPosition
278
- w.writeInt32Field(3, width) // Width
279
- w.writeInt32Field(4, height) // Height
280
- w.writeInt32Field(5, borderWidth) // BorderWidth
281
- w.writeInt32Field(6, borderColor) // BorderColor
282
- w.writeInt32Field(7, borderRadius) // BorderRdaius (sic - typo in proto)
283
- w.writeInt32Field(8, paddingLength) // PaddingLength
284
- w.writeInt32Field(9, containerID) // ContainerID
299
+ w.writeInt32Field(1, x) // XPosition
300
+ w.writeInt32Field(2, y) // YPosition
301
+ w.writeInt32Field(3, width) // Width
302
+ w.writeInt32Field(4, height) // Height
303
+ w.writeInt32Field(5, borderWidth) // BorderWidth
304
+ w.writeInt32Field(6, borderColor) // BorderColor
305
+ w.writeInt32Field(7, borderRadius) // BorderRdaius (sic - typo in proto)
306
+ w.writeInt32Field(8, paddingLength) // PaddingLength
307
+ w.writeInt32Field(9, containerID) // ContainerID
285
308
  if let name = containerName {
286
- w.writeStringField(10, name) // ContainerName
309
+ w.writeStringField(10, name) // ContainerName
287
310
  }
288
- w.writeInt32Field(11, isEventCapture ? 1 : 0) // IsEventCapture
311
+ w.writeInt32Field(11, isEventCapture ? 1 : 0) // IsEventCapture
289
312
  if let content = content {
290
- w.writeStringField(12, content) // Content
313
+ w.writeStringField(12, content) // Content
291
314
  }
292
315
  return w.data
293
316
  }
@@ -298,13 +321,13 @@ private enum EvenHubProto {
298
321
  containerID: Int32, containerName: String? = nil
299
322
  ) -> Data {
300
323
  var w = ProtobufWriter()
301
- w.writeInt32Field(1, x) // XPosition
302
- w.writeInt32Field(2, y) // YPosition
303
- w.writeInt32Field(3, width) // Width
304
- w.writeInt32Field(4, height) // Height
305
- w.writeInt32Field(5, containerID) // ContainerID
324
+ w.writeInt32Field(1, x) // XPosition
325
+ w.writeInt32Field(2, y) // YPosition
326
+ w.writeInt32Field(3, width) // Width
327
+ w.writeInt32Field(4, height) // Height
328
+ w.writeInt32Field(5, containerID) // ContainerID
306
329
  if let name = containerName {
307
- w.writeStringField(6, name) // ContainerName
330
+ w.writeStringField(6, name) // ContainerName
308
331
  }
309
332
  return w.data
310
333
  }
@@ -316,16 +339,16 @@ private enum EvenHubProto {
316
339
  mapFragmentIndex: Int32, mapFragmentPacketSize: Int32, mapRawData: Data
317
340
  ) -> Data {
318
341
  var w = ProtobufWriter()
319
- w.writeInt32Field(1, containerID) // ContainerID
342
+ w.writeInt32Field(1, containerID) // ContainerID
320
343
  if let name = containerName {
321
- w.writeStringField(2, name) // ContainerName
322
- }
323
- w.writeInt32Field(3, mapSessionId) // MapSessionId
324
- w.writeInt32Field(4, mapTotalSize) // MapTotalSize
325
- w.writeInt32Field(5, compressMode) // CompressMode
326
- w.writeInt32Field(6, mapFragmentIndex) // MapFragmentIndex
327
- w.writeInt32Field(7, mapFragmentPacketSize) // MapFragmentPacketSize
328
- w.writeBytesField(8, mapRawData) // MapRawData
344
+ w.writeStringField(2, name) // ContainerName
345
+ }
346
+ w.writeInt32Field(3, mapSessionId) // MapSessionId
347
+ w.writeInt32Field(4, mapTotalSize) // MapTotalSize
348
+ w.writeInt32Field(5, compressMode) // CompressMode
349
+ w.writeInt32Field(6, mapFragmentIndex) // MapFragmentIndex
350
+ w.writeInt32Field(7, mapFragmentPacketSize) // MapFragmentPacketSize
351
+ w.writeBytesField(8, mapRawData) // MapRawData
329
352
  return w.data
330
353
  }
331
354
 
@@ -336,13 +359,13 @@ private enum EvenHubProto {
336
359
  imageContainers: [Data] = []
337
360
  ) -> Data {
338
361
  var w = ProtobufWriter()
339
- w.writeInt32Field(1, containerTotalNum) // ContainerTotalNum
362
+ w.writeInt32Field(1, containerTotalNum) // ContainerTotalNum
340
363
  // field 2 = repeated ListContainerProperty ListObject (not used here)
341
364
  for tc in textContainers {
342
- w.writeMessageField(3, tc) // field 3 = repeated TextObject
365
+ w.writeMessageField(3, tc) // field 3 = repeated TextObject
343
366
  }
344
367
  for ic in imageContainers {
345
- w.writeMessageField(4, ic) // field 4 = repeated ImageObject
368
+ w.writeMessageField(4, ic) // field 4 = repeated ImageObject
346
369
  }
347
370
  return w.data
348
371
  }
@@ -353,17 +376,17 @@ private enum EvenHubProto {
353
376
  contentLength: Int32, content: String
354
377
  ) -> Data {
355
378
  var w = ProtobufWriter()
356
- w.writeInt32Field(1, containerID) // ContainerID
357
- w.writeInt32Field(3, contentOffset) // ContentOffset
358
- w.writeInt32Field(4, contentLength) // ContentLength
359
- w.writeStringField(5, content) // Content
379
+ w.writeInt32Field(1, containerID) // ContainerID
380
+ w.writeInt32Field(3, contentOffset) // ContentOffset
381
+ w.writeInt32Field(4, contentLength) // ContentLength
382
+ w.writeStringField(5, content) // Content
360
383
  return w.data
361
384
  }
362
385
 
363
386
  /// Build a ShutDownContaniner message (sic - typo in proto)
364
387
  static func shutdownContainer(exitMode: Int32 = 0) -> Data {
365
388
  var w = ProtobufWriter()
366
- w.writeInt32Field(1, exitMode) // exitMode
389
+ w.writeInt32Field(1, exitMode) // exitMode
367
390
  return w.data
368
391
  }
369
392
 
@@ -371,7 +394,7 @@ private enum EvenHubProto {
371
394
  static func heartbeatPacket(cnt: Int32 = 0) -> Data {
372
395
  var w = ProtobufWriter()
373
396
  if cnt != 0 {
374
- w.writeInt32Field(1, cnt) // Cnt
397
+ w.writeInt32Field(1, cnt) // Cnt
375
398
  }
376
399
  return w.data
377
400
  }
@@ -379,7 +402,7 @@ private enum EvenHubProto {
379
402
  /// Build an AudioCtrCmd message
380
403
  static func audioCtrCmd(enable: Bool) -> Data {
381
404
  var w = ProtobufWriter()
382
- w.writeInt32Field(1, enable ? 1 : 0) // AudoFuncEn
405
+ w.writeInt32Field(1, enable ? 1 : 0) // AudoFuncEn
383
406
  return w.data
384
407
  }
385
408
 
@@ -390,11 +413,11 @@ private enum EvenHubProto {
390
413
  appId: Int32? = nil
391
414
  ) -> Data {
392
415
  var w = ProtobufWriter()
393
- w.writeInt32Field(1, cmd.rawValue) // Cmd (field 1, enum)
394
- w.writeInt32Field(2, magicRandom) // MagicRandom (field 2)
395
- w.writeMessageField(subFieldNumber, subMessage) // the actual command payload
416
+ w.writeInt32Field(1, cmd.rawValue) // Cmd (field 1, enum)
417
+ w.writeInt32Field(2, magicRandom) // MagicRandom (field 2)
418
+ w.writeMessageField(subFieldNumber, subMessage) // the actual command payload
396
419
  if let appId = appId {
397
- w.writeInt32Field(5, appId) // Associate page with a menu item appId
420
+ w.writeInt32Field(5, appId) // Associate page with a menu item appId
398
421
  }
399
422
  return w.data
400
423
  }
@@ -479,6 +502,48 @@ private enum EvenHubProto {
479
502
  cmd: .audioControl, subFieldNumber: 18, subMessage: audioMsg, magicRandom: magicRandom
480
503
  )
481
504
  }
505
+
506
+ // MARK: - IMU control
507
+ //
508
+ // Wire format recovered by on-device brute-force (sample magnitude ≈ 1.0 g confirms
509
+ // the decode). Shapes from even_hub_sdk@0.0.10; numeric proto tags confirmed live:
510
+ // EvenHub_Cmd_List IMU command = 19
511
+ // evenhub_main_msg_ctx ImuCtrlCmd slot = field 20
512
+ // ImuCtrlCmd { field 1 = IMU_ReportEn (bool), field 2 = reportFrq (pacing 100…1000) }
513
+ // Report path: cmd=2 (osNotifyEventToApp) → SendDeviceEvent.field13 →
514
+ // Sys_ItemEvent { field 1 = eventType = 8 (IMU_DATA_REPORT),
515
+ // field 3 = imuData = IMU_Report_Data }
516
+ // IMU_Report_Data { field 1 = x, 2 = y, 3 = z } — each a 32-bit float (NOT double),
517
+ // gravity-normalized (|v| ≈ 1 at rest).
518
+ static let imuCtrlSubField = 20
519
+
520
+ /// ImuReportPace pacing codes (protocol values, NOT literal Hz). Step 100, 100…1000.
521
+ static let imuPaceP100: Int32 = 100
522
+ static let imuPaceP500: Int32 = 500
523
+ static let imuPaceP1000: Int32 = 1000
524
+
525
+ /// Build an ImuCtrlCmd sub-message.
526
+ static func imuCtrlCmd(enable: Bool, reportFrq: Int32) -> Data {
527
+ var w = ProtobufWriter()
528
+ w.writeInt32Field(1, enable ? 1 : 0) // IMU_ReportEn
529
+ if enable {
530
+ w.writeInt32Field(2, reportFrq) // reportFrq (pacing code 100…1000)
531
+ }
532
+ return w.data
533
+ }
534
+
535
+ /// Build a full evenhub_main_msg_ctx that enables/disables IMU reporting.
536
+ /// `reportFrq` is an ImuReportPace pacing code; ignored when disabling.
537
+ static func imuControlMessage(
538
+ enable: Bool, reportFrq: Int32 = imuPaceP100, magicRandom: Int32 = 0
539
+ ) -> Data {
540
+ let imuMsg = imuCtrlCmd(enable: enable, reportFrq: reportFrq)
541
+ var w = ProtobufWriter()
542
+ w.writeInt32Field(1, EvenHubCmd.imuControl.rawValue) // Cmd
543
+ w.writeInt32Field(2, magicRandom) // MagicRandom
544
+ w.writeMessageField(imuCtrlSubField, imuMsg) // ImuCtrlCmd slot (field 20)
545
+ return w.data
546
+ }
482
547
  }
483
548
 
484
549
  // MARK: - DevSettings Auth Protobuf Builders
@@ -491,17 +556,17 @@ private enum DevSettingsProto {
491
556
  // field 2 = magicRandom (int32)
492
557
  // field 3 = authMgr (AuthMgr message)
493
558
  var w = ProtobufWriter()
494
- w.writeInt32Field(1, DevCfgCommandId.authentication.rawValue) // commandId
495
- w.writeInt32Field(2, magicRandom) // magicRandom
559
+ w.writeInt32Field(1, DevCfgCommandId.authentication.rawValue) // commandId
560
+ w.writeInt32Field(2, magicRandom) // magicRandom
496
561
 
497
562
  // AuthMgr sub-message:
498
563
  // field 1 = secAuth (bool)
499
564
  // field 2 = phoneType (enum eDevice: PHONE_IOS=3, PHONE_ANDROID=4)
500
565
  var authW = ProtobufWriter()
501
- authW.writeBoolField(1, true) // secAuth
502
- authW.writeInt32Field(2, 3) // phoneType = PHONE_IOS (eDevice.PHONE_IOS=3)
566
+ authW.writeBoolField(1, true) // secAuth
567
+ authW.writeInt32Field(2, 3) // phoneType = PHONE_IOS (eDevice.PHONE_IOS=3)
503
568
 
504
- w.writeMessageField(3, authW.data) // authMgr
569
+ w.writeMessageField(3, authW.data) // authMgr
505
570
  return w.data
506
571
  }
507
572
 
@@ -513,24 +578,53 @@ private enum DevSettingsProto {
513
578
 
514
579
  // PipeRoleChange: field 1 = asCmdRole (enum GlassesLR.RIGHT=1)
515
580
  var roleW = ProtobufWriter()
516
- roleW.writeInt32Field(1, 1) // RIGHT
517
- w.writeMessageField(4, roleW.data) // roleChange (field 4 in DevCfgDataPackage)
581
+ roleW.writeInt32Field(1, 1) // RIGHT
582
+ w.writeMessageField(4, roleW.data) // roleChange (field 4 in DevCfgDataPackage)
518
583
  return w.data
519
584
  }
520
585
 
521
- /// DevCfgDataPackage with TIME_SYNC command
586
+ /// DevCfgDataPackage with TIME_SYNC command.
587
+ /// TimeSync submessage: f1 = (Unix seconds + TZ offset seconds) as Int32, no TZ field.
588
+ /// Firmware appears to ignore the TZ field, so we pre-shift the timestamp itself
589
+ /// to make UTC interpretation read as local. Empirically confirmed via probe variants in dbg1().
522
590
  static func timeSync(magicRandom: Int32) -> Data {
523
591
  var w = ProtobufWriter()
524
592
  w.writeInt32Field(1, DevCfgCommandId.timeSync.rawValue)
525
593
  w.writeInt32Field(2, magicRandom)
526
594
 
527
- // TimeSync: field 1 = timestamp (int32), field 2 = timezone (int32)
528
595
  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)
533
- w.writeMessageField(128, tsW.data) // timeSync (field 128 in DevCfgDataPackage)
596
+ let nowSec = Int64(Date().timeIntervalSince1970)
597
+ let tzSec = Int64(TimeZone.current.secondsFromGMT())
598
+ tsW.writeInt32Field(1, Int32(truncatingIfNeeded: nowSec + tzSec))
599
+ w.writeMessageField(128, tsW.data) // timeSync (field 128 in DevCfgDataPackage)
600
+ return w.data
601
+ }
602
+
603
+ /// Parameterized TIME_SYNC for probing the right wire format from dbg1().
604
+ /// - tsField: protobuf field # for the timestamp varint (typically 1)
605
+ /// - tsValue: raw timestamp varint value
606
+ /// - tsBits64: encode timestamp as Int64 (true) or Int32 (false)
607
+ /// - tzField: protobuf field # for TZ (nil to omit entirely)
608
+ /// - tzValue: TZ value to write if tzField != nil
609
+ static func timeSyncVariant(
610
+ magicRandom: Int32,
611
+ tsField: Int, tsValue: Int64, tsBits64: Bool,
612
+ tzField: Int?, tzValue: Int32
613
+ ) -> Data {
614
+ var w = ProtobufWriter()
615
+ w.writeInt32Field(1, DevCfgCommandId.timeSync.rawValue)
616
+ w.writeInt32Field(2, magicRandom)
617
+
618
+ var tsW = ProtobufWriter()
619
+ if tsBits64 {
620
+ tsW.writeInt64Field(tsField, tsValue)
621
+ } else {
622
+ tsW.writeInt32Field(tsField, Int32(truncatingIfNeeded: tsValue))
623
+ }
624
+ if let tzField = tzField {
625
+ tsW.writeInt32Field(tzField, tzValue)
626
+ }
627
+ w.writeMessageField(128, tsW.data)
534
628
  return w.data
535
629
  }
536
630
 
@@ -542,8 +636,8 @@ private enum DevSettingsProto {
542
636
 
543
637
  // BaseConnHeartBeat: empty message
544
638
  var hbW = ProtobufWriter()
545
- _ = hbW // empty
546
- w.writeMessageField(13, hbW.data) // baseHeartBeat (field 13)
639
+ _ = hbW // empty
640
+ w.writeMessageField(13, hbW.data) // baseHeartBeat (field 13)
547
641
  return w.data
548
642
  }
549
643
 
@@ -554,18 +648,18 @@ private enum DevSettingsProto {
554
648
  magicRandom: Int32, connect: Bool, ringMac: Data, ringName: String = ""
555
649
  ) -> Data {
556
650
  var w = ProtobufWriter()
557
- w.writeInt32Field(1, DevCfgCommandId.ringConnectInfo.rawValue) // commandId = RING_CONNECT_INFO (6)
651
+ w.writeInt32Field(1, DevCfgCommandId.ringConnectInfo.rawValue) // commandId = RING_CONNECT_INFO (6)
558
652
  w.writeInt32Field(2, magicRandom)
559
653
 
560
654
  // RingInfo sub-message (field 5 in DevCfgDataPackage)
561
655
  var ringW = ProtobufWriter()
562
- ringW.writeBoolField(1, connect) // connectRing
563
- ringW.writeBytesField(2, ringMac) // ringMac (6 bytes)
656
+ ringW.writeBoolField(1, connect) // connectRing
657
+ ringW.writeBytesField(2, ringMac) // ringMac (6 bytes)
564
658
  if !ringName.isEmpty {
565
- ringW.writeBytesField(3, Data(ringName.utf8)) // ringName
659
+ ringW.writeBytesField(3, Data(ringName.utf8)) // ringName
566
660
  }
567
661
 
568
- w.writeMessageField(5, ringW.data) // ringInfo (field 5)
662
+ w.writeMessageField(5, ringW.data) // ringInfo (field 5)
569
663
  return w.data
570
664
  }
571
665
  }
@@ -577,18 +671,18 @@ private enum G2SettingProto {
577
671
  static func setBrightness(magicRandom: Int32, level: Int32, autoAdjust: Bool) -> Data {
578
672
  // DeviceReceive_Brightness
579
673
  var brightnessW = ProtobufWriter()
580
- brightnessW.writeInt32Field(1, autoAdjust ? 1 : 0) // autoAdjust
581
- brightnessW.writeInt32Field(2, level) // brightnessLevel
674
+ brightnessW.writeInt32Field(1, autoAdjust ? 1 : 0) // autoAdjust
675
+ brightnessW.writeInt32Field(2, level) // brightnessLevel
582
676
 
583
677
  // DeviceReceiveInfoFromAPP
584
678
  var infoW = ProtobufWriter()
585
- infoW.writeMessageField(1, brightnessW.data) // deviceReceiveBrightness (field 1)
679
+ infoW.writeMessageField(1, brightnessW.data) // deviceReceiveBrightness (field 1)
586
680
 
587
681
  // G2SettingPackage
588
682
  var w = ProtobufWriter()
589
- w.writeInt32Field(1, G2SettingCommandId.deviceReceiveInfo.rawValue) // commandId
683
+ w.writeInt32Field(1, G2SettingCommandId.deviceReceiveInfo.rawValue) // commandId
590
684
  w.writeInt32Field(2, magicRandom)
591
- w.writeMessageField(3, infoW.data) // deviceReceiveInfoFromApp (field 3)
685
+ w.writeMessageField(3, infoW.data) // deviceReceiveInfoFromApp (field 3)
592
686
  return w.data
593
687
  }
594
688
 
@@ -597,13 +691,13 @@ private enum G2SettingProto {
597
691
  // DeviceReceiveRequestFromAPP - empty message triggers glasses to respond with all fields
598
692
  var reqW = ProtobufWriter()
599
693
  // Request brightness info type
600
- reqW.writeInt32Field(1, 1) // settingInfoType = APP_REQUIRE_BASIC_SETTING
694
+ reqW.writeInt32Field(1, 1) // settingInfoType = APP_REQUIRE_BASIC_SETTING
601
695
 
602
696
  // G2SettingPackage
603
697
  var w = ProtobufWriter()
604
- w.writeInt32Field(1, G2SettingCommandId.deviceReceiveRequest.rawValue) // commandId
698
+ w.writeInt32Field(1, G2SettingCommandId.deviceReceiveRequest.rawValue) // commandId
605
699
  w.writeInt32Field(2, magicRandom)
606
- w.writeMessageField(4, reqW.data) // deviceReceiveRequestFromApp (field 4)
700
+ w.writeMessageField(4, reqW.data) // deviceReceiveRequestFromApp (field 4)
607
701
  return w.data
608
702
  }
609
703
 
@@ -611,17 +705,17 @@ private enum G2SettingProto {
611
705
  static func setHeadUpSwitch(magicRandom: Int32, enabled: Bool) -> Data {
612
706
  // DeviceReceive_Head_UP_Setting
613
707
  var headUpW = ProtobufWriter()
614
- headUpW.writeInt32Field(1, enabled ? 1 : 0) // headUpSwitch
708
+ headUpW.writeInt32Field(1, enabled ? 1 : 0) // headUpSwitch
615
709
 
616
710
  // DeviceReceiveInfoFromAPP
617
711
  var infoW = ProtobufWriter()
618
- infoW.writeMessageField(4, headUpW.data) // deviceReceiveHeadUpSetting (field 4)
712
+ infoW.writeMessageField(4, headUpW.data) // deviceReceiveHeadUpSetting (field 4)
619
713
 
620
714
  // G2SettingPackage
621
715
  var w = ProtobufWriter()
622
716
  w.writeInt32Field(1, G2SettingCommandId.deviceReceiveInfo.rawValue)
623
717
  w.writeInt32Field(2, magicRandom)
624
- w.writeMessageField(3, infoW.data) // deviceReceiveInfoFromApp (field 3)
718
+ w.writeMessageField(3, infoW.data) // deviceReceiveInfoFromApp (field 3)
625
719
  return w.data
626
720
  }
627
721
 
@@ -629,11 +723,11 @@ private enum G2SettingProto {
629
723
  static func setHeadUpAngle(magicRandom: Int32, angle: Int32) -> Data {
630
724
  // DeviceReceive_Head_UP_Setting
631
725
  var headUpW = ProtobufWriter()
632
- headUpW.writeInt32Field(2, angle) // headUpAngle (field 2)
726
+ headUpW.writeInt32Field(2, angle) // headUpAngle (field 2)
633
727
 
634
728
  // DeviceReceiveInfoFromAPP
635
729
  var infoW = ProtobufWriter()
636
- infoW.writeMessageField(4, headUpW.data) // deviceReceiveHeadUpSetting (field 4)
730
+ infoW.writeMessageField(4, headUpW.data) // deviceReceiveHeadUpSetting (field 4)
637
731
 
638
732
  // G2SettingPackage
639
733
  var w = ProtobufWriter()
@@ -647,11 +741,11 @@ private enum G2SettingProto {
647
741
  static func setScreenHeight(magicRandom: Int32, level: Int32) -> Data {
648
742
  // DeviceReceive_Y_Coordinate
649
743
  var yW = ProtobufWriter()
650
- yW.writeInt32Field(1, level) // yCoordinateLevel
744
+ yW.writeInt32Field(1, level) // yCoordinateLevel
651
745
 
652
746
  // DeviceReceiveInfoFromAPP
653
747
  var infoW = ProtobufWriter()
654
- infoW.writeMessageField(2, yW.data) // deviceReceiveYCoordinate (field 2)
748
+ infoW.writeMessageField(2, yW.data) // deviceReceiveYCoordinate (field 2)
655
749
 
656
750
  // G2SettingPackage
657
751
  var w = ProtobufWriter()
@@ -665,11 +759,11 @@ private enum G2SettingProto {
665
759
  static func setScreenDepth(magicRandom: Int32, level: Int32) -> Data {
666
760
  // DeviceReceive_X_Coordinate
667
761
  var xW = ProtobufWriter()
668
- xW.writeInt32Field(1, level) // xCoordinateLevel
762
+ xW.writeInt32Field(1, level) // xCoordinateLevel
669
763
 
670
764
  // DeviceReceiveInfoFromAPP
671
765
  var infoW = ProtobufWriter()
672
- infoW.writeMessageField(3, xW.data) // deviceReceiveXCoordinate (field 3)
766
+ infoW.writeMessageField(3, xW.data) // deviceReceiveXCoordinate (field 3)
673
767
 
674
768
  // G2SettingPackage
675
769
  var w = ProtobufWriter()
@@ -687,13 +781,13 @@ private enum OnboardingProto {
687
781
  static func skipOnboarding(magicRandom: Int32) -> Data {
688
782
  // OnboardingConfig: processId = FINISH (4)
689
783
  var configW = ProtobufWriter()
690
- configW.writeInt32Field(1, 4) // processId = FINISH
784
+ configW.writeInt32Field(1, 4) // processId = FINISH
691
785
 
692
786
  // OnboardingDataPackage
693
787
  var w = ProtobufWriter()
694
- w.writeInt32Field(1, 1) // commandId = CONFIG
788
+ w.writeInt32Field(1, 1) // commandId = CONFIG
695
789
  w.writeInt32Field(2, magicRandom)
696
- w.writeMessageField(3, configW.data) // config (field 3)
790
+ w.writeMessageField(3, configW.data) // config (field 3)
697
791
  return w.data
698
792
  }
699
793
  }
@@ -701,19 +795,129 @@ private enum OnboardingProto {
701
795
  // MARK: - EvenAI Protobuf Builders (even_ai.proto, service ID 7)
702
796
 
703
797
  private enum EvenAIProto {
704
- /// EvenAIDataPackage with CONFIG command to toggle Hey Even wakeword
705
- /// voiceSwitch: 0 = OFF, 1 = ON
798
+ /// EvenAIDataPackage with CONFIG command to toggle Hey Even wakeword.
799
+ /// voiceSwitch: 0 = OFF, 1 = ON.
800
+ ///
801
+ /// Wire format confirmed by sniffing the official app toggling the setting:
802
+ /// EvenAIConfig (field 13) = { f1=voiceSwitch, f2=32 }
803
+ /// The app OMITS f1 when disabling (proto3 zero) and sends f2=32 (0x20), NOT 80.
804
+ /// Observed echoes: ON → 6A04 08 01 10 20 ({f1:1, f2:32})
805
+ /// OFF → 6A02 10 20 ({f2:32})
706
806
  static func setHeyEven(magicRandom: Int32, enabled: Bool) -> Data {
707
807
  // EvenAIConfig
708
808
  var configW = ProtobufWriter()
709
- configW.writeInt32Field(1, enabled ? 1 : 0) // voiceSwitch
710
- configW.writeInt32Field(2, 80) // streamSpeed (always sent)
809
+ if enabled {
810
+ configW.writeInt32Field(1, 1) // voiceSwitch (omitted when off, matching the app)
811
+ }
812
+ configW.writeInt32Field(2, 32) // streamSpeed (always sent, app uses 32)
711
813
 
712
814
  // EvenAIDataPackage
713
815
  var w = ProtobufWriter()
714
- w.writeInt32Field(1, 10) // commandId = CONFIG
816
+ w.writeInt32Field(1, 10) // commandId = CONFIG
817
+ w.writeInt32Field(2, magicRandom)
818
+ w.writeMessageField(13, configW.data) // config (field 13)
819
+ return w.data
820
+ }
821
+
822
+ /// EvenAIDataPackage with ASK command — what the phone sends after cloud
823
+ /// ASR resolves the user's audio into text. Mirrors Flutter `sendAsr`:
824
+ /// `EvenAIAskInfo { text, streamEnable=0 }`. Used to inject an ASR result
825
+ /// into the glasses' AI session so the following SKILL packet has context.
826
+ static func aiAsk(magicRandom: Int32, text: String, streamEnable: Int32 = 0) -> Data {
827
+ var askW = ProtobufWriter()
828
+ askW.writeInt32Field(2, streamEnable) // streamEnable
829
+ askW.writeBytesField(4, Data(text.utf8)) // text
830
+
831
+ var w = ProtobufWriter()
832
+ w.writeInt32Field(1, 3) // commandId = ASK
833
+ w.writeInt32Field(2, magicRandom)
834
+ w.writeMessageField(5, askW.data) // askInfo (field 5)
835
+ return w.data
836
+ }
837
+
838
+ /// EvenAIDataPackage with CTRL command — used to put glasses into / out of
839
+ /// an AI session. Mirrors Flutter `sendWakeupResp`, which sends
840
+ /// `EvenAIControl { status = EVEN_AI_ENTER }` after the glasses send WAKE_UP.
841
+ /// status: 1 WAKE_UP, 2 ENTER, 3 EXIT
842
+ static func aiCtrl(magicRandom: Int32, status: Int32) -> Data {
843
+ var ctrlW = ProtobufWriter()
844
+ ctrlW.writeInt32Field(1, status) // status
845
+
846
+ var w = ProtobufWriter()
847
+ w.writeInt32Field(1, 1) // commandId = CTRL
848
+ w.writeInt32Field(2, magicRandom)
849
+ w.writeMessageField(3, ctrlW.data) // ctrl (field 3)
850
+ return w.data
851
+ }
852
+
853
+ /// EvenAIDataPackage with SKILL command — triggers a built-in glasses UI
854
+ /// the same way the "Hey Even, show X" voice command does.
855
+ /// skillId values (per even_ai.proto):
856
+ /// 0 SKILL_NONE, 1 BRIGHTNESS, 2 TRANSLATE_CTRL, 3 NOTIFICATION,
857
+ /// 4 TELEPROMPT, 5 NAVIGATE, 6 CONVERSATE, 7 QUICKLIST, 8 AUTO_BRIGHTNESS
858
+ static func triggerSkill(
859
+ magicRandom: Int32, skillId: Int32, skillParam: Int32 = 0,
860
+ text: String = "", streamEnable: Int32 = 1, fTextEnd: Int32 = 1
861
+ ) -> Data {
862
+ // EvenAISkillInfo
863
+ var skillW = ProtobufWriter()
864
+ skillW.writeInt32Field(1, streamEnable) // streamEnable
865
+ skillW.writeInt32Field(2, skillId) // skillId
866
+ skillW.writeInt32Field(3, skillParam) // skillParam — for NOTIFICATION skill this is a NotificationType enum
867
+ skillW.writeBytesField(4, Data(text.utf8)) // text (utterance / payload)
868
+ skillW.writeInt32Field(6, fTextEnd) // fTextEnd — 1 signals "this is the final/complete packet"
869
+
870
+ // EvenAIDataPackage
871
+ var w = ProtobufWriter()
872
+ w.writeInt32Field(1, 6) // commandId = SKILL
873
+ w.writeInt32Field(2, magicRandom)
874
+ w.writeMessageField(8, skillW.data) // skillInfo (field 8)
875
+ return w.data
876
+ }
877
+ }
878
+
879
+ // MARK: - Notification Protobuf Builders (notification.proto, service ID 4)
880
+
881
+ private enum NotificationProto {
882
+ /// NotificationDataPackage with commandId=NOTIFICATION_IOS (2), carrying
883
+ /// `NotificationIOS { appID, displayName }`. We saw the glasses emit this
884
+ /// inbound after "Hey Even, show notifications" with appID="com.burbn.instagram";
885
+ /// trying the same shape outbound to see if the glasses display it.
886
+ /// (Returned errorCode=8 NOT_SUPPORT in testing — Service 4 doesn't accept this outbound.)
887
+ static func iosNotification(magicRandom: Int32, appID: String, displayName: String) -> Data {
888
+ var iosW = ProtobufWriter()
889
+ iosW.writeBytesField(1, Data(appID.utf8)) // appID
890
+ iosW.writeBytesField(2, Data(displayName.utf8)) // displayName
891
+
892
+ var w = ProtobufWriter()
893
+ w.writeInt32Field(1, 2) // commandId = NOTIFICATION_IOS
894
+ w.writeInt32Field(2, magicRandom)
895
+ w.writeMessageField(4, iosW.data) // IOS (field 4)
896
+ return w.data
897
+ }
898
+
899
+ /// NotificationDataPackage with commandId=NOTIFICATION_CTRL (1), carrying
900
+ /// `NotificationControl { notifEnable, autoDispEnable, dispTime, avoidDisturbEnable }`.
901
+ /// Per Flutter `ProtoNotificationExt.settingNotification` this is how the
902
+ /// official app configures notification behavior on the glasses. Worth
903
+ /// testing whether toggling notifEnable also opens the notification panel.
904
+ static func notificationCtrl(
905
+ magicRandom: Int32,
906
+ notifEnable: Int32 = 1,
907
+ autoDispEnable: Int32 = 1,
908
+ dispTime: Int32 = 5,
909
+ avoidDisturbEnable: Int32 = 0
910
+ ) -> Data {
911
+ var ctrlW = ProtobufWriter()
912
+ ctrlW.writeInt32Field(1, notifEnable) // notifEnable
913
+ ctrlW.writeInt32Field(2, autoDispEnable) // autoDispEnable
914
+ ctrlW.writeInt32Field(3, dispTime) // dispTime (seconds)
915
+ ctrlW.writeInt32Field(5, avoidDisturbEnable) // avoidDisturbEnable
916
+
917
+ var w = ProtobufWriter()
918
+ w.writeInt32Field(1, 1) // commandId = NOTIFICATION_CTRL
715
919
  w.writeInt32Field(2, magicRandom)
716
- w.writeMessageField(13, configW.data) // config (field 13)
920
+ w.writeMessageField(3, ctrlW.data) // ctrl (field 3)
717
921
  return w.data
718
922
  }
719
923
  }
@@ -731,7 +935,7 @@ private enum MenuProto {
731
935
  /// G2 firmware requires minimum 5, maximum 10 menu items
732
936
  static let MIN_MENU_SIZE = 5
733
937
  static let MAX_MENU_SIZE = 10
734
- static let MAX_NAME_LENGTH = 15 // 17 char limit minus 2 for running indicator prefix
938
+ static let MAX_NAME_LENGTH = 15 // 17 char limit minus 2 for running indicator prefix
735
939
  /// Placeholder appIds for padding slots (in valid Even range, unique per slot)
736
940
  static let PLACEHOLDER_APP_IDS: [Int32] = [10535, 10536, 10537, 10538, 10539]
737
941
 
@@ -758,7 +962,7 @@ private enum MenuProto {
758
962
 
759
963
  // Wire items carry either a built-in (itemType=0, no name) or third-party (itemType=1, with name)
760
964
  struct WireItem {
761
- let displayName: String? // nil for built-ins
965
+ let displayName: String? // nil for built-ins
762
966
  let appId: Int32
763
967
  let isBuiltIn: Bool
764
968
  }
@@ -775,8 +979,8 @@ private enum MenuProto {
775
979
 
776
980
  let truncated =
777
981
  item.name.count > MAX_NAME_LENGTH
778
- ? String(item.name.prefix(MAX_NAME_LENGTH))
779
- : item.name
982
+ ? String(item.name.prefix(MAX_NAME_LENGTH))
983
+ : item.name
780
984
  let prefix = item.running ? "● " : ""
781
985
  wireItems.append(
782
986
  WireItem(displayName: prefix + truncated, appId: appId, isBuiltIn: false)
@@ -785,7 +989,7 @@ private enum MenuProto {
785
989
 
786
990
  // Pad to MIN_MENU_SIZE with placeholder third-party items
787
991
  while wireItems.count < MIN_MENU_SIZE {
788
- let idx = wireItems.count - 1 // -1 because built-in occupies slot 0
992
+ let idx = wireItems.count - 1 // -1 because built-in occupies slot 0
789
993
  wireItems.append(
790
994
  WireItem(
791
995
  displayName: " ---",
@@ -797,31 +1001,167 @@ private enum MenuProto {
797
1001
 
798
1002
  // MenuInfoSend
799
1003
  var menuW = ProtobufWriter()
800
- menuW.writeInt32Field(1, Int32(wireItems.count)) // itemTotalNum
1004
+ menuW.writeInt32Field(1, Int32(wireItems.count)) // itemTotalNum
801
1005
 
802
1006
  for item in wireItems {
803
1007
  var itemW = ProtobufWriter()
804
1008
  if item.isBuiltIn {
805
- itemW.writeInt32Field(1, 0) // itemType = 0 (built-in)
806
- itemW.writeInt32Field(4, item.appId) // itemAppId = SID
1009
+ itemW.writeInt32Field(1, 0) // itemType = 0 (built-in)
1010
+ itemW.writeInt32Field(4, item.appId) // itemAppId = SID
807
1011
  } else {
808
- itemW.writeInt32Field(1, 1) // itemType = 1 (third-party)
809
- itemW.writeInt32Field(2, 1) // iconNum = 1
810
- itemW.writeStringField(3, item.displayName ?? "") // itemName
811
- itemW.writeInt32Field(4, item.appId) // itemAppId
1012
+ itemW.writeInt32Field(1, 1) // itemType = 1 (third-party)
1013
+ itemW.writeInt32Field(2, 1) // iconNum = 1
1014
+ itemW.writeStringField(3, item.displayName ?? "") // itemName
1015
+ itemW.writeInt32Field(4, item.appId) // itemAppId
812
1016
  }
813
- menuW.writeMessageField(2, itemW.data) // repeated item (field 2)
1017
+ menuW.writeMessageField(2, itemW.data) // repeated item (field 2)
814
1018
  }
815
1019
 
816
1020
  // meun_main_msg_ctx
817
1021
  var w = ProtobufWriter()
818
- w.writeInt32Field(1, 0) // Cmd = APP_SEND_MENU_INFO (0)
819
- w.writeInt32Field(2, magicRandom) // MagicRandom
820
- w.writeMessageField(3, menuW.data) // sendData (field 3)
1022
+ w.writeInt32Field(1, 0) // Cmd = APP_SEND_MENU_INFO (0)
1023
+ w.writeInt32Field(2, magicRandom) // MagicRandom
1024
+ w.writeMessageField(3, menuW.data) // sendData (field 3)
821
1025
  return (w.data, appIdMap)
822
1026
  }
823
1027
  }
824
1028
 
1029
+ // MARK: - Dashboard Protobuf Builders (dashboard.proto, service ID 1)
1030
+
1031
+ /// Builders for the dashboard widget service (service 0x01).
1032
+ /// Field numbers come from the extracted dashboard.proto v2.1.0_beta_v3.
1033
+ private enum DashboardProto {
1034
+ /// eDashboardCommandId values from dashboard.proto
1035
+ enum CommandId: Int32 {
1036
+ case dashboardRespond = 1
1037
+ case dashboardReceive = 2 // phone → glasses widget/config push
1038
+ case appRespond = 3
1039
+ case appReceive = 4
1040
+ }
1041
+
1042
+ /// Build a Schedule submessage (the calendar event payload).
1043
+ /// f1 = scheduleId (int32, required)
1044
+ /// f2 = title (string, optional)
1045
+ /// f3 = location (string, optional)
1046
+ /// f4 = time (string, optional — display text e.g. "10:00 AM")
1047
+ /// f5 = endTimestamp (int32, presumed Unix seconds — pre-shift by TZ
1048
+ /// to match the time-sync hack so glasses display local time)
1049
+ static func schedule(
1050
+ scheduleId: Int32,
1051
+ title: String?,
1052
+ location: String?,
1053
+ time: String?,
1054
+ endTimestamp: Int32
1055
+ ) -> Data {
1056
+ var w = ProtobufWriter()
1057
+ w.writeInt32Field(1, scheduleId)
1058
+ if let title = title { w.writeStringField(2, title) }
1059
+ if let location = location { w.writeStringField(3, location) }
1060
+ if let time = time { w.writeStringField(4, time) }
1061
+ w.writeInt32Field(5, endTimestamp)
1062
+ return w.data
1063
+ }
1064
+
1065
+ /// Build an rScheduleWidget wrapping a single Schedule.
1066
+ /// f1 = scheduleTotal, f2 = scheduleNum (0-based), f3 = Schedule, f4 = scheduleAuthority
1067
+ static func rScheduleWidget(
1068
+ scheduleTotal: Int32,
1069
+ scheduleNum: Int32,
1070
+ schedule: Data,
1071
+ scheduleAuthority: Int32
1072
+ ) -> Data {
1073
+ var w = ProtobufWriter()
1074
+ w.writeInt32Field(1, scheduleTotal)
1075
+ w.writeInt32Field(2, scheduleNum)
1076
+ w.writeMessageField(3, schedule)
1077
+ w.writeInt32Field(4, scheduleAuthority)
1078
+ return w.data
1079
+ }
1080
+
1081
+ /// Build the full calendar-push DashboardDataPackage:
1082
+ /// DashboardDataPackage {
1083
+ /// commandId = Dashboard_Receive (2)
1084
+ /// magicRandom
1085
+ /// dashboardReceive = DashboardReceiveFromApp {
1086
+ /// packageId = 1
1087
+ /// bashboardConfig = DashboardContent {
1088
+ /// widgetComponents = rWidgetComponent {
1089
+ /// schedule = rScheduleWidget { ... }
1090
+ /// }
1091
+ /// }
1092
+ /// }
1093
+ /// }
1094
+ static func calendarPush(
1095
+ magicRandom: Int32,
1096
+ packageId: Int32,
1097
+ scheduleId: Int32,
1098
+ title: String?,
1099
+ location: String?,
1100
+ time: String?,
1101
+ endTimestamp: Int32,
1102
+ scheduleAuthority: Int32,
1103
+ scheduleTotal: Int32 = 1,
1104
+ scheduleNum: Int32 = 0
1105
+ ) -> Data {
1106
+ let sched = schedule(
1107
+ scheduleId: scheduleId, title: title, location: location,
1108
+ time: time, endTimestamp: endTimestamp
1109
+ )
1110
+ let rSched = rScheduleWidget(
1111
+ scheduleTotal: scheduleTotal, scheduleNum: scheduleNum,
1112
+ schedule: sched, scheduleAuthority: scheduleAuthority
1113
+ )
1114
+
1115
+ // rWidgetComponent { f3 = rScheduleWidget }
1116
+ var rWidget = ProtobufWriter()
1117
+ rWidget.writeMessageField(3, rSched)
1118
+
1119
+ // DashboardContent { f2 = rWidgetComponent }
1120
+ var content = ProtobufWriter()
1121
+ content.writeMessageField(2, rWidget.data)
1122
+
1123
+ // DashboardReceiveFromApp { f1 = packageId, f3 = DashboardContent }
1124
+ var receive = ProtobufWriter()
1125
+ receive.writeInt32Field(1, packageId)
1126
+ receive.writeMessageField(3, content.data)
1127
+
1128
+ // DashboardDataPackage { f1 = commandId, f2 = magicRandom, f4 = dashboardReceive }
1129
+ var pkg = ProtobufWriter()
1130
+ pkg.writeInt32Field(1, CommandId.dashboardReceive.rawValue)
1131
+ pkg.writeInt32Field(2, magicRandom)
1132
+ pkg.writeMessageField(4, receive.data)
1133
+ return pkg.data
1134
+ }
1135
+
1136
+ static func calendarClear(
1137
+ magicRandom: Int32,
1138
+ packageId: Int32,
1139
+ scheduleAuthority: Int32
1140
+ ) -> Data {
1141
+ // rScheduleWidget with scheduleTotal=0 clears the widget without sending a stale Schedule.
1142
+ var rSched = ProtobufWriter()
1143
+ rSched.writeInt32Field(1, 0)
1144
+ rSched.writeInt32Field(2, 0)
1145
+ rSched.writeInt32Field(4, scheduleAuthority)
1146
+
1147
+ var rWidget = ProtobufWriter()
1148
+ rWidget.writeMessageField(3, rSched.data)
1149
+
1150
+ var content = ProtobufWriter()
1151
+ content.writeMessageField(2, rWidget.data)
1152
+
1153
+ var receive = ProtobufWriter()
1154
+ receive.writeInt32Field(1, packageId)
1155
+ receive.writeMessageField(3, content.data)
1156
+
1157
+ var pkg = ProtobufWriter()
1158
+ pkg.writeInt32Field(1, CommandId.dashboardReceive.rawValue)
1159
+ pkg.writeInt32Field(2, magicRandom)
1160
+ pkg.writeMessageField(4, receive.data)
1161
+ return pkg.data
1162
+ }
1163
+ }
1164
+
825
1165
  // MARK: - EvenBLE Transport Layer
826
1166
 
827
1167
  /// Builds and splits payloads into BLE packets with the EvenHub transport framing
@@ -839,7 +1179,7 @@ private struct EvenBLETransport {
839
1179
  var offset = 0
840
1180
  while offset < payload.count {
841
1181
  let end = min(offset + maxPayload, payload.count)
842
- chunks.append(payload[offset ..< end])
1182
+ chunks.append(payload[offset..<end])
843
1183
  offset = end
844
1184
  }
845
1185
  if chunks.isEmpty {
@@ -867,20 +1207,20 @@ private struct EvenBLETransport {
867
1207
  let payloadLen = UInt8(chunk.count + (isLast ? 2 : 0))
868
1208
 
869
1209
  var packet = Data()
870
- packet.append(G2BLE.HEADER_BYTE) // [0] 0xAA
871
- packet.append((G2BLE.DEST_GLASSES << 4) | G2BLE.SOURCE_PHONE) // [1] src+dst
872
- packet.append(syncId) // [2] syncId
873
- packet.append(payloadLen) // [3] payloadLen
874
- packet.append(totalPackets) // [4] packetTotalNum
875
- packet.append(serialNum) // [5] packetSerialNum
876
- packet.append(serviceId) // [6] serviceId
877
- packet.append(status) // [7] status
1210
+ packet.append(G2BLE.HEADER_BYTE) // [0] 0xAA
1211
+ packet.append((G2BLE.DEST_GLASSES << 4) | G2BLE.SOURCE_PHONE) // [1] src+dst
1212
+ packet.append(syncId) // [2] syncId
1213
+ packet.append(payloadLen) // [3] payloadLen
1214
+ packet.append(totalPackets) // [4] packetTotalNum
1215
+ packet.append(serialNum) // [5] packetSerialNum
1216
+ packet.append(serviceId) // [6] serviceId
1217
+ packet.append(status) // [7] status
878
1218
 
879
1219
  packet.append(chunk)
880
1220
 
881
1221
  if isLast {
882
- packet.append(UInt8(crc & 0xFF)) // CRC low
883
- packet.append(UInt8((crc >> 8) & 0xFF)) // CRC high
1222
+ packet.append(UInt8(crc & 0xFF)) // CRC low
1223
+ packet.append(UInt8((crc >> 8) & 0xFF)) // CRC high
884
1224
  }
885
1225
 
886
1226
  packets.append(packet)
@@ -920,9 +1260,10 @@ private class G2SendManager {
920
1260
  // MARK: - G2 Receive Manager (multi-part reassembly)
921
1261
 
922
1262
  private class G2ReceiveManager {
923
- private var partials: [String: (Data, UInt8)] = [:] // key -> (accumulated payload, lastSerialNum)
1263
+ private var partials: [String: (Data, UInt8)] = [:] // key -> (accumulated payload, lastSerialNum)
924
1264
 
925
- func handlePacket(_ rawData: Data, sourceKey: String = "") -> (serviceId: UInt8, payload: Data)? {
1265
+ func handlePacket(_ rawData: Data, sourceKey: String = "") -> (serviceId: UInt8, payload: Data)?
1266
+ {
926
1267
  guard rawData.count >= 8 else { return nil }
927
1268
  guard rawData[0] == G2BLE.HEADER_BYTE else { return nil }
928
1269
 
@@ -941,7 +1282,7 @@ private class G2ReceiveManager {
941
1282
  let isLast = (serialNum == totalPackets)
942
1283
  let hasCrc = isLast
943
1284
  let payloadEnd = 8 + payloadLen - (hasCrc ? 2 : 0)
944
- let payload = rawData[8 ..< payloadEnd]
1285
+ let payload = rawData[8..<payloadEnd]
945
1286
 
946
1287
  let syncId = rawData[2]
947
1288
  // Key partials by source peripheral too — left and right glasses have independent syncId counters
@@ -988,7 +1329,7 @@ actor G2ReconnectionManager {
988
1329
  private var task: Task<Void, Never>?
989
1330
  private let intervalSeconds: TimeInterval
990
1331
  private var attempts = 0
991
- private let maxAttempts: Int // -1 for unlimited
1332
+ private let maxAttempts: Int // -1 for unlimited
992
1333
 
993
1334
  init(intervalSeconds: TimeInterval = 30, maxAttempts: Int = -1) {
994
1335
  self.intervalSeconds = intervalSeconds
@@ -1074,12 +1415,18 @@ class G2: NSObject, SGCManager {
1074
1415
  /// Maps serial number -> peripheral UUID string. Persisted across forget() so previously
1075
1416
  /// paired devices can reconnect quickly without a fresh scan.
1076
1417
  private var leftGlassUUIDMap: [String: String] {
1077
- get { UserDefaults.standard.dictionary(forKey: "g2_leftGlassUUIDMap") as? [String: String] ?? [:] }
1418
+ get {
1419
+ UserDefaults.standard.dictionary(forKey: "g2_leftGlassUUIDMap") as? [String: String]
1420
+ ?? [:]
1421
+ }
1078
1422
  set { UserDefaults.standard.set(newValue, forKey: "g2_leftGlassUUIDMap") }
1079
1423
  }
1080
1424
 
1081
1425
  private var rightGlassUUIDMap: [String: String] {
1082
- get { UserDefaults.standard.dictionary(forKey: "g2_rightGlassUUIDMap") as? [String: String] ?? [:] }
1426
+ get {
1427
+ UserDefaults.standard.dictionary(forKey: "g2_rightGlassUUIDMap") as? [String: String]
1428
+ ?? [:]
1429
+ }
1083
1430
  set { UserDefaults.standard.set(newValue, forKey: "g2_rightGlassUUIDMap") }
1084
1431
  }
1085
1432
 
@@ -1110,9 +1457,8 @@ class G2: NSObject, SGCManager {
1110
1457
  private let sendManager = G2SendManager()
1111
1458
  private let receiveManager = G2ReceiveManager()
1112
1459
  private var foregroundObserver: NSObjectProtocol?
1113
- private var startupPageCreated: Bool = false // createStartUpPageContainer can only be called once
1460
+ private var startupPageCreated: Bool = false // createStartUpPageContainer can only be called once
1114
1461
  private var pageCreated: Bool = false
1115
- private var pageHasTextContainer: Bool = false // tracks if current page has a text container
1116
1462
  private var currentTextContent: String = ""
1117
1463
  private var currentBitmapBase64: String = ""
1118
1464
  private var textContainerID: Int32 = 1
@@ -1135,7 +1481,67 @@ class G2: NSObject, SGCManager {
1135
1481
  /// Set to the first menu item's appId so glasses know our page belongs to the menu
1136
1482
  private var activeMenuAppId: Int32?
1137
1483
  private var lastClickTimestamp: Int64?
1484
+ private var lastEvenHubResponseTimestamp: Int64?
1138
1485
  private var lastMenuSelectTimestamp: Int64?
1486
+ private var lastGestureCtrlTimestamp: Int64?
1487
+
1488
+ /// A tracked image container on the current page. Keyed by its rect for reuse.
1489
+ private struct ImgContainer: Equatable {
1490
+ let id: Int32
1491
+ let x: Int32
1492
+ let y: Int32
1493
+ let width: Int32
1494
+ let height: Int32
1495
+ var name: String {
1496
+ "img-\(id)"
1497
+ }
1498
+ var bmpData: Data
1499
+
1500
+ func matches(x: Int32, y: Int32, width: Int32, height: Int32) -> Bool {
1501
+ self.x == x && self.y == y && self.width == width && self.height == height
1502
+ }
1503
+ }
1504
+
1505
+ private struct TextContainer: Equatable {
1506
+ let id: Int32
1507
+ let x: Int32
1508
+ let y: Int32
1509
+ let width: Int32
1510
+ let height: Int32
1511
+ var content: String
1512
+ let borderWidth: Int32
1513
+ let borderColor: Int32
1514
+ let borderRadius: Int32
1515
+ let paddingLength: Int32
1516
+ var name: String {
1517
+ "text-\(id)"
1518
+ }
1519
+
1520
+ func matches(
1521
+ x: Int32, y: Int32, width: Int32, height: Int32, borderWidth: Int32, borderColor: Int32,
1522
+ borderRadius: Int32, paddingLength: Int32
1523
+ ) -> Bool {
1524
+ self.x == x && self.y == y && self.width == width && self.height == height
1525
+ && self.borderWidth == borderWidth && self.borderColor == borderColor
1526
+ && self.borderRadius == borderRadius && self.paddingLength == paddingLength
1527
+ }
1528
+ }
1529
+
1530
+ /// Live list of image containers on the page, ordered oldest→newest (for LRU eviction).
1531
+ /// The page may hold at most 4 image containers (IDs from the pool below).
1532
+ private var imageContainers: [ImgContainer] = []
1533
+ private var textContainers: [TextContainer] = []
1534
+ /// Fixed pool of container IDs the page protocol expects.
1535
+ private let imageContainerIDPool: [Int32] = [10, 11, 12, 13]
1536
+ private let textContainerIDPool: [Int32] = [1, 2, 3, 4, 5, 6]
1537
+ /// Default container seeded into every fresh page: 100x100 in the top-left.
1538
+ private static let defaultImgContainer = (
1539
+ x: Int32(188), y: Int32(44), width: Int32(200), height: Int32(100)
1540
+ )
1541
+ private static let defaultTextContainer = (
1542
+ x: Int32(0), y: Int32(0), width: Int32(576), height: Int32(288), borderWidth: Int32(0),
1543
+ borderColor: Int32(0), borderRadius: Int32(0), paddingLength: Int32(4)
1544
+ )
1139
1545
 
1140
1546
  @Published var aiListening: Bool = false
1141
1547
 
@@ -1159,6 +1565,7 @@ class G2: NSObject, SGCManager {
1159
1565
  // MARK: - BLE Sending
1160
1566
 
1161
1567
  private func sendToGlasses(_ packets: [Data], left: Bool = false, right: Bool = true) {
1568
+ // Bridge.log("G2: sendToGlasses() - sending \(packets.count) packets first byte: \(packets[0][0])")
1162
1569
  for packet in packets {
1163
1570
  if right, let char = rightWriteChar, let peripheral = rightPeripheral {
1164
1571
  peripheral.writeValue(packet, for: char, type: .withoutResponse)
@@ -1186,6 +1593,15 @@ class G2: NSObject, SGCManager {
1186
1593
  sendToGlasses(packets, left: left, right: right)
1187
1594
  }
1188
1595
 
1596
+ private func sendNavigationCommand(_ payload: Data) {
1597
+ let packets = sendManager.buildPackets(
1598
+ serviceId: ServiceID.navigation.rawValue,
1599
+ payload: payload,
1600
+ reserveFlag: true
1601
+ )
1602
+ sendToGlasses(packets)
1603
+ }
1604
+
1189
1605
  private func sendG2SettingCommand(_ payload: Data) {
1190
1606
  let packets = sendManager.buildPackets(
1191
1607
  serviceId: ServiceID.g2Setting.rawValue,
@@ -1213,6 +1629,15 @@ class G2: NSObject, SGCManager {
1213
1629
  sendToGlasses(packets)
1214
1630
  }
1215
1631
 
1632
+ private func sendNotificationCommand(_ payload: Data) {
1633
+ let packets = sendManager.buildPackets(
1634
+ serviceId: ServiceID.notification.rawValue,
1635
+ payload: payload,
1636
+ reserveFlag: true
1637
+ )
1638
+ sendToGlasses(packets)
1639
+ }
1640
+
1216
1641
  private func sendMenuCommand(_ payload: Data) {
1217
1642
  let packets = sendManager.buildPackets(
1218
1643
  serviceId: ServiceID.menu.rawValue,
@@ -1246,7 +1671,7 @@ class G2: NSObject, SGCManager {
1246
1671
  payload: payload,
1247
1672
  reserveFlag: true
1248
1673
  )
1249
- sendToGlasses(packets)
1674
+ sendToGlasses(packets, left: true, right: true)
1250
1675
  }
1251
1676
 
1252
1677
  // MARK: - Authentication Sequence
@@ -1264,7 +1689,7 @@ class G2: NSObject, SGCManager {
1264
1689
  sendDevSettingsCommand(authR, left: false, right: true)
1265
1690
  }
1266
1691
 
1267
- private func runAuthSequence() {
1692
+ private func runAuthSequence() async {
1268
1693
  Bridge.log("G2: Running auth sequence")
1269
1694
 
1270
1695
  // Auth to left side
@@ -1274,245 +1699,150 @@ class G2: NSObject, SGCManager {
1274
1699
  }
1275
1700
 
1276
1701
  // Small delay then auth right + pipe role change + time sync
1277
- DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { [weak self] in
1278
- guard let self = self else { return }
1702
+ try? await Task.sleep(nanoseconds: 200_000_000)
1279
1703
 
1280
- let authR = DevSettingsProto.authCmd(magicRandom: self.sendManager.nextMagicRandom())
1281
- self.sendDevSettingsCommand(authR, left: false, right: true)
1704
+ let authR = DevSettingsProto.authCmd(magicRandom: self.sendManager.nextMagicRandom())
1705
+ self.sendDevSettingsCommand(authR, left: false, right: true)
1282
1706
 
1283
- DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { [weak self] in
1284
- guard let self = self else { return }
1285
-
1286
- let roleChange = DevSettingsProto.pipeRoleChange(
1287
- magicRandom: self.sendManager.nextMagicRandom()
1288
- )
1289
- self.sendDevSettingsCommand(roleChange, left: false, right: true)
1707
+ try? await Task.sleep(nanoseconds: 200_000_000)
1290
1708
 
1291
- DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { [weak self] in
1292
- guard let self = self else { return }
1293
-
1294
- let timeSync = DevSettingsProto.timeSync(
1295
- magicRandom: self.sendManager.nextMagicRandom()
1296
- )
1297
- self.sendDevSettingsCommand(timeSync)
1298
-
1299
- // Skip onboarding on connect
1300
- DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { [weak self] in
1301
- guard let self = self else { return }
1302
- let onboarding = OnboardingProto.skipOnboarding(
1303
- magicRandom: self.sendManager.nextMagicRandom()
1304
- )
1305
- self.sendOnboardingCommand(onboarding)
1306
- Bridge.log("G2: Sent onboarding skip (FINISH)")
1307
-
1308
- // Disable "Hey Even" wakeword on connect
1309
- let heyEvenOff = EvenAIProto.setHeyEven(
1310
- magicRandom: self.sendManager.nextMagicRandom(),
1311
- enabled: false
1312
- )
1313
- self.sendEvenAICommand(heyEvenOff)
1314
- Bridge.log("G2: Disabled Hey Even wakeword")
1315
-
1316
- // Replicate Even app's full init sequence for menu selection support:
1317
-
1318
- // 0. Universe settings (g2_setting cmd=1 field3 with field9=universe settings)
1319
- // Even app's bytes: 4a 0a 08 00 10 00 18 01 20 00 28 01
1320
- // = field 9 (universe), {1:0, 2:0, 3:1, 4:0, 5:1}
1321
- var univW = ProtobufWriter()
1322
- univW.writeInt32Field(1, 1) // DeviceReceiveInfo
1323
- univW.writeInt32Field(2, self.sendManager.nextMagicRandom())
1324
- univW.writeMessageField(
1325
- 3,
1326
- Data([
1327
- 0x4A, 0x0A, // field 9, length 10
1328
- 0x08, 0x00, // unitFormat=0
1329
- 0x10, 0x00, // distanceUnit=0
1330
- 0x18, 0x01, // timeFormat=1
1331
- 0x20, 0x00, // dateFormat=0
1332
- 0x28, 0x01, // temperatureUnit=1
1333
- ])
1334
- )
1335
- self.sendG2SettingCommand(univW.data)
1336
-
1337
- // 1. gesture_ctrl init (field1=0, field2=magicRandom)
1338
- var gestureInitW = ProtobufWriter()
1339
- gestureInitW.writeInt32Field(1, 0)
1340
- gestureInitW.writeInt32Field(2, self.sendManager.nextMagicRandom())
1341
- self.sendGestureCtrlCommand(gestureInitW.data)
1342
-
1343
- // 2. ui_setting_app (0x0C) — query (cmd=2, field4={settingInfoType=1, autoBrightnessLevel=0})
1344
- var uiSettW = ProtobufWriter()
1345
- uiSettW.writeInt32Field(1, 2) // cmd = DeviceReceiveRequest
1346
- uiSettW.writeInt32Field(2, self.sendManager.nextMagicRandom())
1347
- uiSettW.writeMessageField(4, Data([0x08, 0x01, 0x10, 0x00])) // {1:1, 2:0}
1348
- self.sendToGlasses(
1349
- self.sendManager.buildPackets(
1350
- serviceId: 0x0C, payload: uiSettW.data, reserveFlag: true
1351
- )
1352
- )
1353
-
1354
- // 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
- )
1384
-
1385
- // 6. Dashboard init (0x01) — display settings
1386
- var dashDisplayW = ProtobufWriter()
1387
- dashDisplayW.writeInt32Field(1, 4) // displayMode
1388
- dashDisplayW.writeInt32Field(2, 3) // statusDisplayCount
1389
- dashDisplayW.writeMessageField(3, Data([1, 2, 3])) // statusDisplayOrder
1390
- 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
1394
-
1395
- var dashRecvW = ProtobufWriter()
1396
- dashRecvW.writeMessageField(2, dashDisplayW.data)
1397
-
1398
- var dashPkgW = ProtobufWriter()
1399
- dashPkgW.writeInt32Field(1, 2) // Dashboard_Receive
1400
- dashPkgW.writeInt32Field(2, self.sendManager.nextMagicRandom())
1401
- dashPkgW.writeMessageField(4, dashRecvW.data)
1402
- self.sendDashboardCommand(dashPkgW.data)
1403
-
1404
- // 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)
1431
-
1432
- Bridge.log("G2: Sent full Even-compatible init sequence")
1433
- }
1434
-
1435
- // Start heartbeats after auth
1436
- self.startHeartbeats()
1709
+ let roleChange = DevSettingsProto.pipeRoleChange(
1710
+ magicRandom: self.sendManager.nextMagicRandom()
1711
+ )
1712
+ self.sendDevSettingsCommand(roleChange, left: false, right: true)
1437
1713
 
1438
- Task { await self.reconnectionManager.stop() }
1439
- Bridge.log("G2: Auth sequence complete, glasses ready")
1714
+ try? await Task.sleep(nanoseconds: 200_000_000)
1440
1715
 
1441
- // Set device_name so DeviceManager can save it for reconnection
1442
- if let peripheralName = self.rightPeripheral?.name
1443
- ?? self.leftPeripheral?.name,
1444
- let serialNumber = self.deviceNameToSerialNumber[peripheralName]
1445
- {
1446
- DeviceStore.shared.apply("bluetooth", "device_name", serialNumber)
1447
- Bridge.log("G2: Set device_name to \(serialNumber)")
1448
- }
1716
+ let timeSync = DevSettingsProto.timeSync(
1717
+ magicRandom: self.sendManager.nextMagicRandom()
1718
+ )
1719
+ self.sendDevSettingsCommand(timeSync)
1449
1720
 
1450
- // Set bluetooth name and device model for Device Info page
1451
- let btName =
1452
- self.rightPeripheral?.name
1453
- ?? self.leftPeripheral?.name ?? ""
1454
- DeviceStore.shared.apply("glasses", "bluetoothName", btName)
1455
- DeviceStore.shared.apply("glasses", "deviceModel", DeviceTypes.G2)
1721
+ // Skip onboarding on connect
1722
+ try? await Task.sleep(nanoseconds: 200_000_000)
1723
+ let onboarding = OnboardingProto.skipOnboarding(
1724
+ magicRandom: self.sendManager.nextMagicRandom()
1725
+ )
1726
+ self.sendOnboardingCommand(onboarding)
1727
+ Bridge.log("G2: Sent onboarding skip (FINISH)")
1728
+
1729
+ // 1. gesture_ctrl init (field1=0, field2=magicRandom)
1730
+ var gestureInitW = ProtobufWriter()
1731
+ gestureInitW.writeInt32Field(1, 0)
1732
+ gestureInitW.writeInt32Field(2, self.sendManager.nextMagicRandom())
1733
+ self.sendGestureCtrlCommand(gestureInitW.data)
1734
+
1735
+ // 2. ui_setting_app (0x0C) — query (cmd=2, field4={settingInfoType=1, autoBrightnessLevel=0})
1736
+ var uiSettW = ProtobufWriter()
1737
+ uiSettW.writeInt32Field(1, 2) // cmd = DeviceReceiveRequest
1738
+ uiSettW.writeInt32Field(2, self.sendManager.nextMagicRandom())
1739
+ uiSettW.writeMessageField(4, Data([0x08, 0x01, 0x10, 0x00])) // {1:1, 2:0}
1740
+ self.sendToGlasses(
1741
+ self.sendManager.buildPackets(
1742
+ serviceId: 0x0C, payload: uiSettW.data, reserveFlag: true
1743
+ )
1744
+ )
1456
1745
 
1457
- self.setFullyConnected()
1746
+ // 6. Dashboard init (0x01) — display settings
1747
+ // halfDayFormat: 1 = 12h, 0 = 24h
1748
+ // temperatureUnit: 1 = Celsius (metric), 2 = Fahrenheit (imperial)
1749
+ var dashDisplayW = ProtobufWriter()
1750
+ dashDisplayW.writeInt32Field(1, 4) // displayMode
1751
+ dashDisplayW.writeInt32Field(2, 3) // statusDisplayCount
1752
+ dashDisplayW.writeMessageField(3, Data([1, 2, 3])) // statusDisplayOrder
1753
+ dashDisplayW.writeInt32Field(4, 4) // widgetDisplayCount
1754
+ // WidgetType: 1=News, 2=Stock, 3=Schedule, 4=Quicklist, 5=Health
1755
+ dashDisplayW.writeMessageField(5, Data([3, 1, 2, 4, 5])) // widgetDisplayOrder: Schedule, News, Stock, Quicklist
1756
+ dashDisplayW.writeInt32Field(6, self.dashboardHalfDayFormat()) // halfDayFormat
1757
+ dashDisplayW.writeInt32Field(7, self.dashboardTemperatureUnit()) // temperatureUnit
1758
+
1759
+ var dashRecvW = ProtobufWriter()
1760
+ dashRecvW.writeMessageField(2, dashDisplayW.data)
1761
+
1762
+ var dashPkgW = ProtobufWriter()
1763
+ dashPkgW.writeInt32Field(1, 2) // Dashboard_Receive
1764
+ dashPkgW.writeInt32Field(2, self.sendManager.nextMagicRandom())
1765
+ dashPkgW.writeMessageField(4, dashRecvW.data)
1766
+ self.sendDashboardCommand(dashPkgW.data)
1767
+
1768
+ // Disable "Hey Even" wakeword on connect
1769
+ let heyEvenOff = EvenAIProto.setHeyEven(
1770
+ magicRandom: self.sendManager.nextMagicRandom(),
1771
+ enabled: false
1772
+ )
1773
+ self.sendEvenAICommand(heyEvenOff)
1774
+ Bridge.log("G2: Disabled Hey Even wakeword")
1775
+
1776
+ // 7. Dashboard REQUEST_NEWS_INFO (cmd=5, field7={1:1})
1777
+ // var dashNewsReqW = ProtobufWriter()
1778
+ // dashNewsReqW.writeInt32Field(1, 5) // REQUEST_NEWS_INFO
1779
+ // dashNewsReqW.writeInt32Field(2, self.sendManager.nextMagicRandom())
1780
+ // dashNewsReqW.writeMessageField(7, Data([0x08, 0x01])) // {1:1}
1781
+ // self.sendDashboardCommand(dashNewsReqW.data)
1782
+
1783
+ // // 8. Gesture control list via g2_setting
1784
+ // var gestListW = ProtobufWriter()
1785
+ // gestListW.writeInt32Field(1, 1) // DeviceReceiveInfo
1786
+ // gestListW.writeInt32Field(2, self.sendManager.nextMagicRandom())
1787
+ // // field 3 with field 10 (gestureControlList): 3 items, all app_unable
1788
+ // let gestureCtrlPayload = Data([
1789
+ // 0x52, 0x18, // field 10, length 24
1790
+ // 0x0A, 0x06, 0x08, 0x00, 0x10, 0x00, 0x18, 0x00, // item 1
1791
+ // 0x0A, 0x06, 0x08, 0x00, 0x10, 0x01, 0x18, 0x00, // item 2
1792
+ // 0x0A, 0x06, 0x08, 0x00, 0x10, 0x02, 0x18, 0x00, // item 3
1793
+ // ])
1794
+ // gestListW.writeMessageField(3, gestureCtrlPayload)
1795
+ // self.sendG2SettingCommand(gestListW.data)
1796
+
1797
+ // // 9. Dashboard APP_REQUEST_NEWS_INFO (cmd=7, field9={1:1})
1798
+ // var dashAppNewsW = ProtobufWriter()
1799
+ // dashAppNewsW.writeInt32Field(1, 7) // APP_REQUEST_NEWS_INFO
1800
+ // dashAppNewsW.writeInt32Field(2, self.sendManager.nextMagicRandom())
1801
+ // dashAppNewsW.writeMessageField(9, Data([0x08, 0x01])) // {1:1}
1802
+ // self.sendDashboardCommand(dashAppNewsW.data)
1803
+
1804
+ Bridge.log("G2: Sent full Even-compatible init sequence")
1805
+
1806
+ // Start heartbeats after auth
1807
+ self.startHeartbeats()
1808
+
1809
+ Task { await self.reconnectionManager.stop() }
1810
+ Bridge.log("G2: Auth sequence complete, glasses ready")
1811
+
1812
+ // Set device_name so DeviceManager can save it for reconnection
1813
+ if let peripheralName = self.rightPeripheral?.name
1814
+ ?? self.leftPeripheral?.name,
1815
+ let serialNumber = self.deviceNameToSerialNumber[peripheralName]
1816
+ {
1817
+ DeviceStore.shared.apply("bluetooth", "device_name", serialNumber)
1818
+ Bridge.log("G2: Set device_name to \(serialNumber)")
1819
+ }
1458
1820
 
1459
- // connnect a controller if we have one:
1460
- self.connectController()
1821
+ // Set bluetooth name and device model for Device Info page
1822
+ let btName =
1823
+ self.rightPeripheral?.name
1824
+ ?? self.leftPeripheral?.name ?? ""
1825
+ DeviceStore.shared.apply("glasses", "bluetoothName", btName)
1826
+ DeviceStore.shared.apply("glasses", "deviceModel", DeviceTypes.G2)
1461
1827
 
1462
- // Query version + battery info from glasses
1463
- self.requestDeviceInfo()
1828
+ self.setFullyConnected()
1464
1829
 
1465
- // send dashboard menu if we have stored items
1466
- self.sendMenuApps()
1467
- }
1468
- }
1469
- }
1470
- }
1830
+ // connnect a controller if we have one:
1831
+ self.connectController()
1471
1832
 
1472
- private func runDashboardSequence() {
1473
- Bridge.log("G2: Running dashboard sequence")
1833
+ // Query version + battery info from glasses
1834
+ self.requestDeviceInfo()
1474
1835
 
1475
- // send the shutdown command to the glasses:
1476
- let msg = EvenHubProto.shutdownMessage()
1477
- sendEvenHubCommand(msg)
1478
- pageCreated = false
1479
- currentTextContent = ""
1836
+ // send dashboard menu if we have stored items
1837
+ self.sendMenuApps()
1480
1838
 
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
- // }
1839
+ // order the calendar (Schedule) widget first on the dashboard
1840
+ self.setCalendarWidgetFirst()
1486
1841
 
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
- }
1842
+ // send calendar events
1843
+ let calendarEvents =
1844
+ DeviceStore.shared.get("bluetooth", "calendar_events") as? [[String: Any]] ?? []
1845
+ self.sendCalendarEvents(calendarEvents)
1516
1846
  }
1517
1847
 
1518
1848
  // MARK: - Heartbeats
@@ -1521,7 +1851,7 @@ class G2: NSObject, SGCManager {
1521
1851
  heartbeatTask?.cancel()
1522
1852
  heartbeatTask = Task { [weak self] in
1523
1853
  while !Task.isCancelled {
1524
- try? await Task.sleep(nanoseconds: 5_000_000_000)
1854
+ try? await Task.sleep(nanoseconds: 10_000_000_000)
1525
1855
  guard !Task.isCancelled else { break }
1526
1856
  await MainActor.run {
1527
1857
  self?.sendEvenHubHeartbeat()
@@ -1596,55 +1926,112 @@ class G2: NSObject, SGCManager {
1596
1926
 
1597
1927
  // MARK: - SGCManager: Display Control
1598
1928
 
1599
- func sendTextWall(_ text: String) {
1929
+ func sendTextWall(_ text: String) async {
1930
+ await sendText(
1931
+ text,
1932
+ x: G2.defaultTextContainer.x,
1933
+ y: G2.defaultTextContainer.y,
1934
+ width: G2.defaultTextContainer.width,
1935
+ height: G2.defaultTextContainer.height,
1936
+ borderWidth: G2.defaultTextContainer.borderWidth,
1937
+ borderColor: G2.defaultTextContainer.borderColor,
1938
+ borderRadius: G2.defaultTextContainer.borderRadius,
1939
+ paddingLength: G2.defaultTextContainer.paddingLength
1940
+ )
1941
+ }
1942
+
1943
+ func sendText(
1944
+ _ text: String, x: Int32? = nil, y: Int32? = nil, width: Int32? = nil, height: Int32? = nil,
1945
+ borderWidth: Int32? = nil, borderColor: Int32? = nil, borderRadius: Int32? = nil,
1946
+ paddingLength: Int32? = nil
1947
+ ) async {
1600
1948
  // Bridge.log("G2: sendTextWall(\(text.prefix(50))...)")
1601
1949
 
1602
1950
  // ignore events while the ER dashboard is open:
1603
- let useNativeDashboard = DeviceStore.shared.get("bluetooth", "use_native_dashboard") as? Bool ?? false
1951
+ let useNativeDashboard =
1952
+ DeviceStore.shared.get("bluetooth", "use_native_dashboard") as? Bool ?? false
1604
1953
  if useNativeDashboard && dashboardShowing > 0 {
1605
1954
  return
1606
1955
  }
1607
1956
 
1608
- if text.isEmpty {
1609
- clearDisplay()
1957
+ let rx = x ?? G2.defaultTextContainer.x
1958
+ let ry = y ?? G2.defaultTextContainer.y
1959
+ let rw = width ?? G2.defaultTextContainer.width
1960
+ let rh = height ?? G2.defaultTextContainer.height
1961
+ let borderWidth = G2.defaultTextContainer.borderWidth
1962
+ let borderColor = G2.defaultTextContainer.borderColor
1963
+ let borderRadius = G2.defaultTextContainer.borderRadius
1964
+ let paddingLength = G2.defaultTextContainer.paddingLength
1965
+ let content = text.isEmpty ? " " : text
1966
+
1967
+ // Reuse an existing container if the rect matches exactly; otherwise add a new one.
1968
+ var container: TextContainer
1969
+ if let i = textContainers.firstIndex(where: {
1970
+ $0.matches(
1971
+ x: rx, y: ry, width: rw, height: rh, borderWidth: borderWidth,
1972
+ borderColor: borderColor, borderRadius: borderRadius, paddingLength: paddingLength)
1973
+ }) {
1974
+ textContainers[i].content = content
1975
+ container = textContainers[i]
1976
+ Bridge.log(
1977
+ "G2: sendText() - reusing container \(container.id) for rect \(rx),\(ry) \(rw)x\(rh)"
1978
+ )
1979
+ if !pageCreated {
1980
+ await rebuildPage()
1981
+ return
1982
+ }
1983
+ let msg = EvenHubProto.updateTextMessage(
1984
+ containerID: container.id,
1985
+ contentOffset: 0,
1986
+ contentLength: Int32(text.utf8.count),
1987
+ content: container.content
1988
+ )
1989
+ queueEvenHubCommand(msg)
1610
1990
  return
1611
1991
  }
1612
1992
 
1613
- if !pageCreated || !pageHasTextContainer {
1614
- // Need to create/rebuild page with a text container
1615
- // Bridge.log("G2: sendTextWall() - creating page with text container")
1616
- createPageWithText(text)
1617
- } else {
1618
- // Bridge.log("G2: sendTextWall() - updating text container")
1619
- updateText(text)
1620
- }
1993
+ container = addTextContainer(
1994
+ x: rx, y: ry, width: rw, height: rh, content: content, borderWidth: borderWidth,
1995
+ borderColor: borderColor, borderRadius: borderRadius, paddingLength: paddingLength)
1996
+ Bridge.log(
1997
+ "G2: sendText() - added text container \(container.id) for rect \(rx),\(ry) \(rw)x\(rh), rebuilding page"
1998
+ )
1999
+ await rebuildPage()
1621
2000
  }
1622
2001
 
1623
- func sendDoubleTextWall(_ top: String, _ bottom: String) {
2002
+ func sendDoubleTextWall(_ top: String, _ bottom: String) async {
2003
+ Bridge.log("G2: sendDoubleTextWall() - top: \(top), bottom: \(bottom)")
1624
2004
  // G2 doesn't have native double text wall, combine them
1625
- let combined = top + "\n" + bottom
1626
- sendTextWall(combined)
2005
+ let combined = top + "\n\n" + bottom
2006
+ await sendTextWall(combined)
1627
2007
  }
1628
2008
 
1629
2009
  func clearDisplay() {
1630
2010
  Bridge.log("G2: clearDisplay()")
1631
2011
  // Don't shutdown the EvenHub page — that kills audio streaming too.
1632
2012
  // Instead, just clear the text content by sending a space.
1633
- // if pageCreated {
1634
- // let msg = EvenHubProto.shutdownMessage()
1635
- // sendEvenHubCommand(msg)
1636
- // pageCreated = false
1637
- // currentTextContent = ""
1638
- // }
1639
- if pageCreated {
1640
- sendTextWall(" ")
2013
+
2014
+ if !pageCreated {
2015
+ Bridge.log("G2: clearDisplay() - page not created")
2016
+ createPageWithContainers()
2017
+ }
2018
+
2019
+ // reset the content of all text containers to empty:
2020
+ for i in textContainers.indices {
2021
+ textContainers[i].content = " "
2022
+ }
2023
+ for i in imageContainers.indices {
2024
+ imageContainers[i].bmpData = Data()
1641
2025
  }
2026
+ // shutdown the page and then recreate the containers without the content:
2027
+ let msg = EvenHubProto.shutdownMessage()
2028
+ sendEvenHubCommand(msg)
2029
+ createPageWithContainers()
2030
+ restartMicIfAlreadyEnabled()
1642
2031
  }
1643
2032
 
1644
2033
  /// Send BMP data to an image container via fragmented updateImageRawData
1645
- private func sendImageData(containerID: Int32, containerName: String, bmpData: Data) async
1646
- -> Bool
1647
- {
2034
+ private func sendImageData(containerID: Int32, containerName: String, bmpData: Data) async {
1648
2035
  let fragmentSize = 4096
1649
2036
  imageSessionCounter += 1
1650
2037
  let sessionId = imageSessionCounter
@@ -1652,9 +2039,13 @@ class G2: NSObject, SGCManager {
1652
2039
  var fragmentIndex: Int32 = 0
1653
2040
  var offset = 0
1654
2041
 
2042
+ Bridge.log(
2043
+ "G2: sendImageData(\(containerName)) - \(fragmentIndex) fragments, \(bmpData.count) bytes"
2044
+ )
2045
+
1655
2046
  while offset < bmpData.count {
1656
2047
  let end = min(offset + fragmentSize, bmpData.count)
1657
- let fragment = bmpData[offset ..< end]
2048
+ let fragment = bmpData[offset..<end]
1658
2049
 
1659
2050
  let msg = EvenHubProto.updateImageRawDataMessage(
1660
2051
  containerID: containerID,
@@ -1670,149 +2061,152 @@ class G2: NSObject, SGCManager {
1670
2061
 
1671
2062
  fragmentIndex += 1
1672
2063
  offset = end
1673
- try? await Task.sleep(nanoseconds: 200_000_000) // 200ms between fragments
2064
+ try? await Task.sleep(nanoseconds: 200_000_000) // 200ms between fragments
1674
2065
  }
1675
-
1676
- Bridge.log(
1677
- "G2: sendImageData(\(containerName)) - \(fragmentIndex) fragments, \(bmpData.count) bytes"
1678
- )
1679
- return true
1680
2066
  }
1681
2067
 
1682
- func displayBitmapLoc(rawData: Data, x: Int32, y: Int32, id: Int32) async -> Bool {
1683
- Bridge.log("G2: displayBitmap() - decoded \(rawData.count) bytes from base64")
2068
+ /// Display a bitmap inside a positioned image container.
2069
+ ///
2070
+ /// The page keeps a live list of up to 4 image containers keyed by exact rect:
2071
+ /// - If a container with the requested rect already exists, the image is just resent to it
2072
+ /// (no page rebuild).
2073
+ /// - Otherwise a new container is added (evicting the oldest when the list would exceed 4) and
2074
+ /// the page is rebuilt before the image is sent.
2075
+ ///
2076
+ /// Omitted params default to a 100x100 container in the top-left corner.
2077
+ func displayBitmap(
2078
+ base64ImageData: String, x: Int32? = nil, y: Int32? = nil, width: Int32? = nil,
2079
+ height: Int32? = nil
2080
+ ) async -> Bool {
2081
+ let rx = x ?? G2.defaultImgContainer.x
2082
+ let ry = y ?? G2.defaultImgContainer.y
2083
+ let rw = width ?? G2.defaultImgContainer.width
2084
+ let rh = height ?? G2.defaultImgContainer.height
1684
2085
 
1685
- Bridge.log(
1686
- "G2: displayBitmap() - state: startupPageCreated=\(startupPageCreated), pageCreated=\(pageCreated)"
1687
- )
1688
-
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")
2086
+ // ignore events while the ER dashboard is open:
2087
+ let useNativeDashboard =
2088
+ DeviceStore.shared.get("bluetooth", "use_native_dashboard") as? Bool ?? false
2089
+ if useNativeDashboard && dashboardShowing > 0 {
1693
2090
  return false
1694
2091
  }
1695
2092
 
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)"
2093
+ guard let rawData = Data(base64Encoded: base64ImageData) else {
2094
+ Bridge.log("G2: failed to decode base64")
2095
+ return false
2096
+ }
1703
2097
 
1704
- let imageContainer = EvenHubProto.imageContainerProperty(
1705
- x: containerX, y: containerY,
1706
- width: containerW, height: containerH,
1707
- containerID: containerID, containerName: containerName
1708
- )
2098
+ guard
2099
+ let bmpData = convertToG2Bmp(rawData, containerWidth: Int(rw), containerHeight: Int(rh))
2100
+ else {
2101
+ Bridge.log("G2: failed to convert image to BMP")
2102
+ return false
2103
+ }
1709
2104
 
1710
- let msg: Data
1711
- 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
2105
+ // Reuse an existing container if the rect matches exactly; otherwise add a new one.
2106
+ var container: ImgContainer
2107
+ if let i = imageContainers.firstIndex(where: {
2108
+ $0.matches(x: rx, y: ry, width: rw, height: rh)
2109
+ }) {
2110
+ imageContainers[i].bmpData = bmpData
2111
+ container = imageContainers[i]
2112
+ Bridge.log(
2113
+ "G2: displayBitmap() - reusing container \(container.id) for rect \(rx),\(ry) \(rw)x\(rh)"
1716
2114
  )
1717
- startupPageCreated = true
2115
+ if !pageCreated {
2116
+ await rebuildPage()
2117
+ return true
2118
+ }
2119
+ await sendImageData(
2120
+ containerID: container.id, containerName: container.name, bmpData: container.bmpData
2121
+ )
2122
+ return true
1718
2123
  } else {
1719
- Bridge.log("G2: displayBitmap() - rebuilding page with image container")
1720
- msg = EvenHubProto.rebuildPageMessage(
1721
- imageContainers: [imageContainer], magicRandom: sendManager.nextMagicRandom(),
1722
- appId: activeMenuAppId
2124
+ container = addImageContainer(x: rx, y: ry, width: rw, height: rh, bmpData: bmpData)
2125
+ Bridge.log(
2126
+ "G2: displayBitmap() - added container \(container.id) for rect \(rx),\(ry) \(rw)x\(rh), rebuilding page"
1723
2127
  )
2128
+ await rebuildPage()
1724
2129
  }
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
-
1732
- // Send the BMP data
1733
- let success = await sendImageData(
1734
- containerID: containerID, containerName: containerName, bmpData: bmpData
1735
- )
1736
- if !success {
1737
- Bridge.log("G2: displayBitmap() - failed sending image data")
1738
- }
1739
-
1740
- Bridge.log("G2: displayBitmap() - single tile sent, \(bmpData.count) bytes")
1741
- return success
2130
+ return true
1742
2131
  }
1743
2132
 
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
2133
+ /// Add a new image container for `rect`, evicting the oldest when the list is full (max 4).
2134
+ /// Returns the newly tracked container (with an assigned ID from the pool).
2135
+ private func addImageContainer(x: Int32, y: Int32, width: Int32, height: Int32, bmpData: Data)
2136
+ -> ImgContainer
2137
+ {
2138
+ // Evict the oldest container when at capacity, freeing its ID for reuse.
2139
+ if imageContainers.count >= imageContainerIDPool.count {
2140
+ let evicted = imageContainers.removeFirst()
2141
+ Bridge.log("G2: evicting oldest image container \(evicted.id)")
1748
2142
  }
1749
-
1750
- guard let tiles = renderAndSliceTo4Tiles(rawData) else {
1751
- Bridge.log("G2: displayBitmapQuad() - failed to slice image into tiles")
1752
- return false
2143
+ // Pick the lowest free ID from the pool.
2144
+ let usedIDs = Set(imageContainers.map { $0.id })
2145
+ let id = imageContainerIDPool.first { !usedIDs.contains($0) } ?? imageContainerIDPool[0]
2146
+ let container = ImgContainer(
2147
+ id: id, x: x, y: y, width: width, height: height, bmpData: bmpData)
2148
+ imageContainers.append(container)
2149
+ return container
2150
+ }
2151
+
2152
+ private func addTextContainer(
2153
+ x: Int32, y: Int32, width: Int32, height: Int32, content: String, borderWidth: Int32,
2154
+ borderColor: Int32, borderRadius: Int32, paddingLength: Int32
2155
+ ) -> TextContainer {
2156
+ // Evict the oldest container when at capacity, freeing its ID for reuse.
2157
+ if textContainers.count >= textContainerIDPool.count {
2158
+ let evicted = textContainers.removeFirst()
2159
+ Bridge.log("G2: evicting oldest text container \(evicted.id)")
1753
2160
  }
2161
+ // Pick the lowest free ID from the pool.
2162
+ let usedIDs = Set(textContainers.map { $0.id })
2163
+ let id = textContainerIDPool.first { !usedIDs.contains($0) } ?? textContainerIDPool[0]
2164
+ let container = TextContainer(
2165
+ id: id, x: x, y: y, width: width, height: height, content: content,
2166
+ borderWidth: borderWidth, borderColor: borderColor, borderRadius: borderRadius,
2167
+ paddingLength: paddingLength)
2168
+ textContainers.append(container)
2169
+ return container
2170
+ }
2171
+
2172
+ /// shutdown and rebuild everything, re-sends all data to the glasses:
2173
+ private func rebuildPage() async {
2174
+ let msg = EvenHubProto.shutdownMessage()
2175
+ sendEvenHubCommand(msg)
2176
+ pageCreated = false
2177
+ await rebuildState()
2178
+ }
1754
2179
 
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
- )
2180
+ // re-creates the containers and sends all images and text again to the glasses:
2181
+ private func rebuildState() async {
2182
+ Bridge.log("G2: rebuildState()")
2183
+ // recreate the containers:
2184
+ createPageWithContainers()
1772
2185
 
1773
- let msg: Data
1774
- if !startupPageCreated {
1775
- msg = EvenHubProto.createPageMessage(
1776
- imageContainers: [
1777
- container1, container2, container3, container4,
1778
- ], magicRandom: sendManager.nextMagicRandom(), appId: activeMenuAppId
2186
+ try? await Task.sleep(nanoseconds: 300_000_000) // 300ms to settle
2187
+ // send any image containers we have
2188
+ // go through each container and send the data:
2189
+ for container in imageContainers {
2190
+ Bridge.log(
2191
+ "G2: rebuildState() - sending image data to container \(container.id), \(container.bmpData.count) bytes"
1779
2192
  )
1780
- startupPageCreated = true
1781
- } else {
1782
- msg = EvenHubProto.rebuildPageMessage(
1783
- imageContainers: [
1784
- container1, container2, container3, container4,
1785
- ], magicRandom: sendManager.nextMagicRandom(), appId: activeMenuAppId
2193
+ await sendImageData(
2194
+ containerID: container.id, containerName: container.name, bmpData: container.bmpData
1786
2195
  )
2196
+ try? await Task.sleep(nanoseconds: 300_000_000) // 300ms between containers
1787
2197
  }
1788
- sendEvenHubCommand(msg)
1789
- pageCreated = true
1790
- pageHasTextContainer = false
1791
- currentTextContent = ""
1792
2198
 
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
1810
- }
1811
-
1812
- func displayBitmap(base64ImageData: String) async -> Bool {
1813
- currentBitmapBase64 = base64ImageData
1814
- currentTextContent = ""
1815
- return await displayBitmapQuad(base64ImageData: base64ImageData)
2199
+ // go through each text container and send the data:
2200
+ // disabled because text containers are initialized with their content:
2201
+ // for container in textContainers {
2202
+ // let msg = EvenHubProto.updateTextMessage(
2203
+ // containerID: container.id,
2204
+ // contentOffset: 0,
2205
+ // contentLength: Int32(container.content.utf8.count),
2206
+ // content: container.content
2207
+ // )
2208
+ // sendEvenHubCommand(msg)
2209
+ // }
1816
2210
  }
1817
2211
 
1818
2212
  /// Upscale BMP pixel data by 2x (200x100 → 400x200) using nearest-neighbor
@@ -1825,7 +2219,7 @@ class G2: NSObject, SGCManager {
1825
2219
  return nil
1826
2220
  }
1827
2221
 
1828
- let srcPaddedRowSize = ((srcWidth + 1) / 2 + 3) & ~3 // 4-bit rows padded to 4 bytes
2222
+ let srcPaddedRowSize = ((srcWidth + 1) / 2 + 3) & ~3 // 4-bit rows padded to 4 bytes
1829
2223
  let pixelDataOffset = headerSize
1830
2224
 
1831
2225
  let dstWidth = srcWidth * 2
@@ -1858,7 +2252,7 @@ class G2: NSObject, SGCManager {
1858
2252
  dst.appendLittleEndian(UInt32(0))
1859
2253
 
1860
2254
  // --- Color Table (same 16-entry grayscale) ---
1861
- for i in 0 ..< 16 {
2255
+ for i in 0..<16 {
1862
2256
  let val = UInt8(i * 17)
1863
2257
  dst.append(contentsOf: [val, val, val, 0])
1864
2258
  }
@@ -1866,12 +2260,12 @@ class G2: NSObject, SGCManager {
1866
2260
  // --- Pixel Data (nearest-neighbor 2x upscale) ---
1867
2261
  // BMP is bottom-up, so row 0 = bottom of image
1868
2262
  // Each dst row maps to srcRow = dstRow / 2
1869
- for dstRow in 0 ..< dstHeight {
2263
+ for dstRow in 0..<dstHeight {
1870
2264
  let srcRow = dstRow / 2
1871
2265
  let srcRowOffset = pixelDataOffset + srcRow * srcPaddedRowSize
1872
2266
  var rowBuf = [UInt8](repeating: 0, count: dstPaddedRowSize)
1873
2267
 
1874
- for dstCol in 0 ..< dstWidth {
2268
+ for dstCol in 0..<dstWidth {
1875
2269
  let srcCol = dstCol / 2
1876
2270
 
1877
2271
  // Read 4-bit nibble from source
@@ -1897,73 +2291,6 @@ class G2: NSObject, SGCManager {
1897
2291
  return dst
1898
2292
  }
1899
2293
 
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
2294
  // MARK: - Bitmap Conversion
1968
2295
 
1969
2296
  /// Scale source image to fit within containerWidth x containerHeight (maintaining aspect ratio),
@@ -1987,9 +2314,9 @@ class G2: NSObject, SGCManager {
1987
2314
  let offsetX = (containerWidth - scaledW) / 2
1988
2315
  let offsetY = (containerHeight - scaledH) / 2
1989
2316
 
1990
- Bridge.log(
1991
- "G2: convertToG2Bmp - input \(srcWidth)x\(srcHeight) → scaled \(scaledW)x\(scaledH) in \(containerWidth)x\(containerHeight)"
1992
- )
2317
+ // Bridge.log(
2318
+ // "G2: convertToG2Bmp - input \(srcWidth)x\(srcHeight) → scaled \(scaledW)x\(scaledH) in \(containerWidth)x\(containerHeight)"
2319
+ // )
1993
2320
 
1994
2321
  // Render to 8-bit grayscale at the CONTAINER size (not scaled size)
1995
2322
  guard
@@ -2013,7 +2340,7 @@ class G2: NSObject, SGCManager {
2013
2340
  ctx.draw(cgImage, in: CGRect(x: offsetX, y: offsetY, width: scaledW, height: scaledH))
2014
2341
 
2015
2342
  guard let renderedImage = ctx.makeImage(),
2016
- let pixels = renderedImage.dataProvider?.data as Data?
2343
+ let pixels = renderedImage.dataProvider?.data as Data?
2017
2344
  else {
2018
2345
  Bridge.log("G2: convertToG2Bmp - failed to get pixel data")
2019
2346
  return nil
@@ -2031,99 +2358,12 @@ class G2: NSObject, SGCManager {
2031
2358
  return bmp
2032
2359
  }
2033
2360
 
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
2361
  /// Build a 4-bit indexed BMP file from 8-bit grayscale pixel data.
2122
2362
  /// BMP rows are stored bottom-up. Each row is padded to a 4-byte boundary.
2123
2363
  private func build4BitBmp(grayscalePixels: Data, width: Int, height: Int) -> Data? {
2124
2364
  // 4-bit: 2 pixels per byte, rows padded to 4-byte boundary
2125
- let bytesPerRow4bit = (width + 1) / 2 // ceil(width / 2)
2126
- let paddedRowSize = (bytesPerRow4bit + 3) & ~3 // pad to 4-byte boundary
2365
+ let bytesPerRow4bit = (width + 1) / 2 // ceil(width / 2)
2366
+ let paddedRowSize = (bytesPerRow4bit + 3) & ~3 // pad to 4-byte boundary
2127
2367
  let pixelDataSize = paddedRowSize * height
2128
2368
 
2129
2369
  // BMP file header (14 bytes) + DIB header (40 bytes) + color table (16 * 4 = 64 bytes)
@@ -2133,46 +2373,46 @@ class G2: NSObject, SGCManager {
2133
2373
  var bmp = Data(capacity: fileSize)
2134
2374
 
2135
2375
  // --- BMP File Header (14 bytes) ---
2136
- bmp.append(contentsOf: [0x42, 0x4D]) // "BM" signature
2137
- bmp.appendLittleEndian(UInt32(fileSize)) // File size
2138
- bmp.appendLittleEndian(UInt16(0)) // Reserved1
2139
- bmp.appendLittleEndian(UInt16(0)) // Reserved2
2140
- bmp.appendLittleEndian(UInt32(headerSize)) // Pixel data offset
2376
+ bmp.append(contentsOf: [0x42, 0x4D]) // "BM" signature
2377
+ bmp.appendLittleEndian(UInt32(fileSize)) // File size
2378
+ bmp.appendLittleEndian(UInt16(0)) // Reserved1
2379
+ bmp.appendLittleEndian(UInt16(0)) // Reserved2
2380
+ bmp.appendLittleEndian(UInt32(headerSize)) // Pixel data offset
2141
2381
 
2142
2382
  // --- DIB Header (BITMAPINFOHEADER, 40 bytes) ---
2143
- bmp.appendLittleEndian(UInt32(40)) // DIB header size
2144
- bmp.appendLittleEndian(Int32(width)) // Width
2145
- bmp.appendLittleEndian(Int32(height)) // Height (positive = bottom-up)
2146
- bmp.appendLittleEndian(UInt16(1)) // Color planes
2147
- bmp.appendLittleEndian(UInt16(4)) // Bits per pixel (4-bit)
2148
- bmp.appendLittleEndian(UInt32(0)) // Compression (none)
2149
- bmp.appendLittleEndian(UInt32(pixelDataSize)) // Image size
2150
- bmp.appendLittleEndian(Int32(2835)) // X pixels/meter (~72 DPI)
2151
- bmp.appendLittleEndian(Int32(2835)) // Y pixels/meter
2152
- bmp.appendLittleEndian(UInt32(16)) // Colors used
2153
- bmp.appendLittleEndian(UInt32(0)) // Important colors (0 = all)
2383
+ bmp.appendLittleEndian(UInt32(40)) // DIB header size
2384
+ bmp.appendLittleEndian(Int32(width)) // Width
2385
+ bmp.appendLittleEndian(Int32(height)) // Height (positive = bottom-up)
2386
+ bmp.appendLittleEndian(UInt16(1)) // Color planes
2387
+ bmp.appendLittleEndian(UInt16(4)) // Bits per pixel (4-bit)
2388
+ bmp.appendLittleEndian(UInt32(0)) // Compression (none)
2389
+ bmp.appendLittleEndian(UInt32(pixelDataSize)) // Image size
2390
+ bmp.appendLittleEndian(Int32(2835)) // X pixels/meter (~72 DPI)
2391
+ bmp.appendLittleEndian(Int32(2835)) // Y pixels/meter
2392
+ bmp.appendLittleEndian(UInt32(16)) // Colors used
2393
+ bmp.appendLittleEndian(UInt32(0)) // Important colors (0 = all)
2154
2394
 
2155
2395
  // --- Color Table (16 entries, 4 bytes each: B, G, R, 0) ---
2156
- for i in 0 ..< 16 {
2157
- let val = UInt8(i * 17) // 0, 17, 34, ... 255 (evenly spaced grayscale)
2158
- bmp.append(contentsOf: [val, val, val, 0]) // B, G, R, Reserved
2396
+ for i in 0..<16 {
2397
+ let val = UInt8(i * 17) // 0, 17, 34, ... 255 (evenly spaced grayscale)
2398
+ bmp.append(contentsOf: [val, val, val, 0]) // B, G, R, Reserved
2159
2399
  }
2160
2400
 
2161
2401
  // --- Pixel Data (bottom-up rows, 4-bit packed) ---
2162
2402
  let rowBytes = [UInt8](repeating: 0, count: paddedRowSize)
2163
- for row in 0 ..< height {
2403
+ for row in 0..<height {
2164
2404
  // BMP is bottom-up: row 0 in BMP = last row of image
2165
2405
  let srcRow = height - 1 - row
2166
2406
  let srcOffset = srcRow * width
2167
2407
  var rowBuf = rowBytes
2168
2408
 
2169
- for col in 0 ..< width {
2409
+ for col in 0..<width {
2170
2410
  let pixelIndex = srcOffset + col
2171
2411
  guard pixelIndex < grayscalePixels.count else { continue }
2172
2412
 
2173
2413
  // Map 8-bit grayscale (0-255) to 4-bit index (0-15)
2174
2414
  let gray8 = grayscalePixels[pixelIndex]
2175
- let index4 = gray8 >> 4 // divide by 16
2415
+ let index4 = gray8 >> 4 // divide by 16
2176
2416
 
2177
2417
  let bytePos = col / 2
2178
2418
  if col % 2 == 0 {
@@ -2186,9 +2426,9 @@ class G2: NSObject, SGCManager {
2186
2426
  bmp.append(contentsOf: rowBuf)
2187
2427
  }
2188
2428
 
2189
- Bridge.log(
2190
- "G2: build4BitBmp - \(bmp.count) bytes (header=\(headerSize), pixels=\(pixelDataSize), rows=\(paddedRowSize)x\(height))"
2191
- )
2429
+ // Bridge.log(
2430
+ // "G2: build4BitBmp - \(bmp.count) bytes (header=\(headerSize), pixels=\(pixelDataSize), rows=\(paddedRowSize)x\(height))"
2431
+ // )
2192
2432
  return bmp
2193
2433
  }
2194
2434
 
@@ -2196,13 +2436,11 @@ class G2: NSObject, SGCManager {
2196
2436
  /// the foreground by tearing down whatever EvenHub page we currently own.
2197
2437
  /// The glasses fall back to the dashboard automatically when no page is up.
2198
2438
  func showDashboard() {
2199
- Bridge.log("G2: showDashboard")
2439
+ Bridge.log("G2: showDashboard()")
2200
2440
  dashboardShowing += 2
2201
2441
  let msg = EvenHubProto.shutdownMessage()
2202
2442
  sendEvenHubCommand(msg)
2203
2443
  pageCreated = false
2204
- pageHasTextContainer = false
2205
- currentTextContent = ""
2206
2444
  currentBitmapBase64 = ""
2207
2445
  DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak self] in
2208
2446
  guard let self = self else { return }
@@ -2212,6 +2450,114 @@ class G2: NSObject, SGCManager {
2212
2450
  }
2213
2451
  }
2214
2452
 
2453
+ private func dashboardHalfDayFormat() -> Int32 {
2454
+ let twelveHour = DeviceStore.shared.get("bluetooth", "twelve_hour_time") as? Bool ?? true
2455
+ return twelveHour ? 1 : 0
2456
+ }
2457
+
2458
+ private func dashboardTemperatureUnit() -> Int32 {
2459
+ let metric = DeviceStore.shared.get("bluetooth", "metric_system") as? Bool ?? false
2460
+ return metric ? 1 : 2
2461
+ }
2462
+
2463
+ func sendDashboardDisplaySettings() {
2464
+ var dashDisplayW = ProtobufWriter()
2465
+ dashDisplayW.writeInt32Field(1, 4) // displayMode
2466
+ dashDisplayW.writeInt32Field(2, 3) // statusDisplayCount
2467
+ dashDisplayW.writeMessageField(3, Data([1, 2, 3])) // statusDisplayOrder
2468
+ dashDisplayW.writeInt32Field(4, 4) // widgetDisplayCount
2469
+ // WidgetType: 1=News, 2=Stock, 3=Schedule, 4=Quicklist, 5=Health
2470
+ dashDisplayW.writeMessageField(5, Data([3, 1, 2, 4, 5]))
2471
+ dashDisplayW.writeInt32Field(6, dashboardHalfDayFormat()) // halfDayFormat
2472
+ dashDisplayW.writeInt32Field(7, dashboardTemperatureUnit()) // temperatureUnit
2473
+
2474
+ var dashRecvW = ProtobufWriter()
2475
+ dashRecvW.writeMessageField(2, dashDisplayW.data)
2476
+
2477
+ var dashPkgW = ProtobufWriter()
2478
+ dashPkgW.writeInt32Field(1, 2) // Dashboard_Receive
2479
+ dashPkgW.writeInt32Field(2, sendManager.nextMagicRandom())
2480
+ dashPkgW.writeMessageField(4, dashRecvW.data)
2481
+ sendDashboardCommand(dashPkgW.data)
2482
+ }
2483
+
2484
+ /// Push a calendar event to the dashboard's Schedule widget (service 0x01).
2485
+ ///
2486
+ /// - title: event title displayed on the widget
2487
+ /// - location: optional location string
2488
+ /// - time: pre-formatted display string (e.g. "10:00 AM" or "10:00 – 10:30").
2489
+ /// The widget shows this verbatim — format it however you want it to read.
2490
+ /// - endDate: when the event ends. Encoded the same way as the time-sync hack:
2491
+ /// Unix seconds with the local TZ offset folded in, so the glasses (which
2492
+ /// appear to treat timestamps as already-local) read it correctly.
2493
+ /// - scheduleId: stable per-event id. Reuse the same id when updating an event.
2494
+ func sendCalendarEvent(
2495
+ title: String,
2496
+ location: String? = nil,
2497
+ time: String? = nil,
2498
+ endDate: Date,
2499
+ scheduleId: Int32 = 1,
2500
+ scheduleTotal: Int32 = 1,
2501
+ scheduleNum: Int32 = 0
2502
+ ) {
2503
+ Bridge.log("G2: sendCalendarEvent(\(title), endDate=\(endDate))")
2504
+ let tzSec = Int64(TimeZone.current.secondsFromGMT())
2505
+ let endTs = Int32(truncatingIfNeeded: Int64(endDate.timeIntervalSince1970) + tzSec)
2506
+
2507
+ let payload = DashboardProto.calendarPush(
2508
+ magicRandom: sendManager.nextMagicRandom(),
2509
+ packageId: 1,
2510
+ scheduleId: scheduleId,
2511
+ title: title,
2512
+ location: location,
2513
+ time: time,
2514
+ endTimestamp: endTs,
2515
+ scheduleAuthority: 1,
2516
+ scheduleTotal: scheduleTotal,
2517
+ scheduleNum: scheduleNum
2518
+ )
2519
+ sendDashboardCommand(payload)
2520
+ }
2521
+
2522
+ /// Bridge entry for `calendar_events` store updates. Each dict is expected
2523
+ /// to match the TS `CalendarEvent` shape: { title, location?, time, endDate }
2524
+ /// where endDate is unix seconds.
2525
+ ///
2526
+ /// Sends one BLE push per event, with `scheduleTotal` set to the batch size
2527
+ /// and `scheduleNum` set to this event's 0-based slot. The widget pages
2528
+ /// through them on the glasses — without paging info the firmware overwrites
2529
+ /// slot 0 on each push and only the last event survives.
2530
+ func sendCalendarEvents(_ events: [[String: Any]]) {
2531
+ Bridge.log("G2: sendCalendarEvents — \(events.count) events")
2532
+ if events.isEmpty {
2533
+ let payload = DashboardProto.calendarClear(
2534
+ magicRandom: sendManager.nextMagicRandom(),
2535
+ packageId: 1,
2536
+ scheduleAuthority: 1
2537
+ )
2538
+ sendDashboardCommand(payload)
2539
+ return
2540
+ }
2541
+
2542
+ let total = Int32(events.count)
2543
+ for (i, ev) in events.enumerated() {
2544
+ guard let title = ev["title"] as? String,
2545
+ let time = ev["time"] as? String,
2546
+ let endTs = ev["endDate"] as? Double
2547
+ else { continue }
2548
+ let location = ev["location"] as? String
2549
+ sendCalendarEvent(
2550
+ title: title,
2551
+ location: location,
2552
+ time: time,
2553
+ endDate: Date(timeIntervalSince1970: endTs),
2554
+ scheduleId: Int32(i + 1),
2555
+ scheduleTotal: total,
2556
+ scheduleNum: Int32(i)
2557
+ )
2558
+ }
2559
+ }
2560
+
2215
2561
  func setDashboardPosition(_ height: Int, _ depth: Int) {
2216
2562
  Bridge.log("G2: setDashboardPosition(height=\(height), depth=\(depth))")
2217
2563
  setDashboardHeightOnly(height)
@@ -2250,47 +2596,65 @@ class G2: NSObject, SGCManager {
2250
2596
 
2251
2597
  // MARK: - Private Display Helpers
2252
2598
 
2253
- private func createPageWithText(_ text: String) {
2254
- let tc = EvenHubProto.textContainerProperty(
2255
- x: 0, y: 0, width: 576, height: 288,
2256
- borderWidth: 0, borderColor: 0, borderRadius: 0,
2257
- paddingLength: 4, containerID: textContainerID,
2258
- containerName: "text-main", isEventCapture: true,
2259
- content: text
2260
- )
2599
+ private func createPageWithContainers() {
2600
+ // build the page's text containers from the live tracked list.
2601
+ // iterate by index not using map:
2602
+ var textContainerProps: [Data] = []
2603
+ for (index, c) in textContainers.enumerated() {
2604
+ textContainerProps.append(
2605
+ EvenHubProto.textContainerProperty(
2606
+ x: c.x, y: c.y, width: c.width, height: c.height,
2607
+ borderWidth: c.borderWidth, borderColor: c.borderColor,
2608
+ borderRadius: c.borderRadius,
2609
+ paddingLength: c.paddingLength, containerID: c.id,
2610
+ containerName: c.name, isEventCapture: index == 0, // the first container is the event capture container
2611
+ content: c.content
2612
+ ))
2613
+ }
2614
+
2615
+ // iterate all image containers, remove any entrys with duplicate id's, and ensure the ids in the imageContainerIDPool is up-to-date:
2616
+ var seenIDs = Set<Int32>()
2617
+ imageContainers = imageContainers.filter { c in
2618
+ guard !c.bmpData.isEmpty else {
2619
+ Bridge.log("G2: removing empty image container \(c.id)")
2620
+ return false
2621
+ }
2622
+ guard !seenIDs.contains(c.id) else {
2623
+ Bridge.log("G2: removing duplicate image container \(c.id)")
2624
+ return false
2625
+ }
2626
+ seenIDs.insert(c.id)
2627
+ return imageContainerIDPool.contains(c.id)
2628
+ }
2629
+
2630
+ // Build the page's image containers from the live tracked list.
2631
+ let imageContainerProps: [Data] = imageContainers.map { c in
2632
+ EvenHubProto.imageContainerProperty(
2633
+ x: c.x, y: c.y, width: c.width, height: c.height,
2634
+ containerID: c.id, containerName: c.name
2635
+ )
2636
+ }
2261
2637
 
2262
2638
  let msg: Data
2263
- if !startupPageCreated {
2264
- Bridge.log("G2: createPageWithText - using createPageMessage (first time)")
2639
+ if !pageCreated {
2640
+ Bridge.log("G2: createPageWithContainers() - using createPageMessage (first time)")
2265
2641
  msg = EvenHubProto.createPageMessage(
2266
- textContainers: [tc], magicRandom: sendManager.nextMagicRandom(),
2642
+ textContainers: textContainerProps,
2643
+ imageContainers: imageContainerProps,
2644
+ magicRandom: sendManager.nextMagicRandom(),
2267
2645
  appId: activeMenuAppId
2268
2646
  )
2269
- startupPageCreated = true
2270
2647
  } else {
2271
- Bridge.log("G2: createPageWithText - using rebuildPageMessage")
2648
+ Bridge.log("G2: createPageWithContainers() - using rebuildPageMessage")
2272
2649
  msg = EvenHubProto.rebuildPageMessage(
2273
- textContainers: [tc], magicRandom: sendManager.nextMagicRandom(),
2650
+ textContainers: textContainerProps,
2651
+ imageContainers: imageContainerProps,
2652
+ magicRandom: sendManager.nextMagicRandom(),
2274
2653
  appId: activeMenuAppId
2275
2654
  )
2276
2655
  }
2277
2656
  sendEvenHubCommand(msg)
2278
2657
  pageCreated = true
2279
- pageHasTextContainer = true
2280
- currentTextContent = text
2281
- currentBitmapBase64 = ""
2282
- }
2283
-
2284
- private func updateText(_ text: String) {
2285
- let msg = EvenHubProto.updateTextMessage(
2286
- containerID: textContainerID,
2287
- contentOffset: 0,
2288
- contentLength: Int32(text.utf8.count),
2289
- content: text
2290
- )
2291
- queueEvenHubCommand(msg)
2292
- currentTextContent = text
2293
- currentBitmapBase64 = ""
2294
2658
  }
2295
2659
 
2296
2660
  private func queueEvenHubCommand(_ payload: Data) {
@@ -2319,6 +2683,13 @@ class G2: NSObject, SGCManager {
2319
2683
  sendEvenHubCommand(toSend)
2320
2684
  }
2321
2685
 
2686
+ private func restartMicIfAlreadyEnabled() {
2687
+ let currentEnabled = DeviceStore.shared.get("glasses", "micEnabled") as? Bool ?? false
2688
+ if currentEnabled {
2689
+ restartMic()
2690
+ }
2691
+ }
2692
+
2322
2693
  func restartMic() {
2323
2694
  // if already enabled, set to disabled, then send enabled after 500ms:
2324
2695
  DeviceStore.shared.apply("glasses", "micEnabled", true)
@@ -2326,13 +2697,14 @@ class G2: NSObject, SGCManager {
2326
2697
  sendEvenHubCommand(msg)
2327
2698
  DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak self] in
2328
2699
  guard let self = self else { return }
2329
- let useNativeDashboard = DeviceStore.shared.get("bluetooth", "use_native_dashboard") as? Bool ?? false
2330
- Bridge.log("G2: setMicEnabled - useNativeDashboard=\(useNativeDashboard), dashboardShowing=\(dashboardShowing)")
2700
+ let useNativeDashboard =
2701
+ DeviceStore.shared.get("bluetooth", "use_native_dashboard") as? Bool ?? false
2702
+ // Bridge.log("G2: setMicEnabled - useNativeDashboard=\(useNativeDashboard), dashboardShowing=\(dashboardShowing)")
2331
2703
  if useNativeDashboard && dashboardShowing > 0 {
2332
2704
  return
2333
2705
  }
2334
- if !pageCreated || !pageHasTextContainer {
2335
- DeviceManager.shared.sendCurrentState() // should re-create the page if needed
2706
+ if !pageCreated {
2707
+ DeviceManager.shared.sendCurrentState() // should re-create the page if needed
2336
2708
  }
2337
2709
  let msg = EvenHubProto.audioControlMessage(enable: true)
2338
2710
  self.sendEvenHubCommand(msg)
@@ -2419,7 +2791,7 @@ class G2: NSObject, SGCManager {
2419
2791
  rightAuthenticated = false
2420
2792
  startupPageCreated = false
2421
2793
  pageCreated = false
2422
- pageHasTextContainer = false
2794
+ dashboardShowing = 0
2423
2795
  heartbeatCounter = 0
2424
2796
  DeviceStore.shared.apply("glasses", "connected", false)
2425
2797
  DeviceStore.shared.apply("glasses", "fullyBooted", false)
@@ -2517,53 +2889,137 @@ class G2: NSObject, SGCManager {
2517
2889
  Bridge.log("G2: Sent RING_DISCONNECT_INFO for MAC \(mac)")
2518
2890
  }
2519
2891
 
2520
- func dbg1() {
2521
- Bridge.log("G2: dbg1()")
2522
-
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
- // }
2892
+ /// Fire an EvenAI skill — the same path "Hey Even, show X" uses. Triggers a
2893
+ /// built-in glasses UI (notification list, navigation, teleprompter, etc).
2894
+ /// See `EvenAIProto.triggerSkill` for the skillId table.
2895
+ func triggerSkill(
2896
+ _ skillId: Int32, skillParam: Int32 = 0,
2897
+ text: String = "", streamEnable: Int32 = 1, fTextEnd: Int32 = 1
2898
+ ) {
2899
+ Bridge.log(
2900
+ "G2: triggerSkill(\(skillId), skillParam=\(skillParam), text=\"\(text)\", streamEnable=\(streamEnable), fTextEnd=\(fTextEnd))"
2901
+ )
2902
+ let payload = EvenAIProto.triggerSkill(
2903
+ magicRandom: sendManager.nextMagicRandom(),
2904
+ skillId: skillId,
2905
+ skillParam: skillParam,
2906
+ text: text,
2907
+ streamEnable: streamEnable,
2908
+ fTextEnd: fTextEnd
2909
+ )
2910
+ sendEvenAICommand(payload)
2911
+ }
2912
+
2913
+ /// Open the on-glasses notification panel — same effect as the user saying
2914
+ /// "Hey Even, show notifications". Replicates the official-app voice flow:
2915
+ /// 1. CTRL{status=ENTER} — puts glasses in AI session
2916
+ /// 2. ASK{text=" "} — minimal ASR transcript to seed session context
2917
+ /// 3. SKILL{skillId=NOTIFICATION, skillParam=show, ...} — dispatches the intent
2918
+ /// The SKILL step alone is ignored by firmware; the preceding ENTER+ASK
2919
+ /// supply the session context that lets the glasses act on the SKILL.
2920
+ func showNotificationsPanel() async {
2921
+ Bridge.log("G2: showNotificationsPanel()")
2922
+
2923
+ let enterPayload = EvenAIProto.aiCtrl(
2924
+ magicRandom: sendManager.nextMagicRandom(),
2925
+ status: 2 // EVEN_AI_ENTER
2926
+ )
2927
+ sendEvenAICommand(enterPayload)
2928
+
2929
+ try? await Task.sleep(nanoseconds: 400_000_000)
2930
+ let askPayload = EvenAIProto.aiAsk(
2931
+ magicRandom: sendManager.nextMagicRandom(),
2932
+ text: " ",
2933
+ streamEnable: 0
2934
+ )
2935
+ sendEvenAICommand(askPayload)
2535
2936
 
2536
- // connectController("1B:08:26:8E:0E:E6")
2537
- // connectController()
2538
- showDashboard()
2937
+ try? await Task.sleep(nanoseconds: 400_000_000)
2938
+ triggerSkill(
2939
+ 3, skillParam: 1, // NOTIFICATION, show
2940
+ text: " ",
2941
+ streamEnable: 1, fTextEnd: 1
2942
+ )
2539
2943
  }
2540
2944
 
2541
- func dbg2() {
2542
- Bridge.log("G2: dbg2()")
2945
+ func dbg1() {
2946
+ setCalendarWidgetFirst()
2947
+ // toggleHeyEven()
2948
+ }
2543
2949
 
2544
- // createPageWithText("test1")
2950
+ private var compassRunning = false
2545
2951
 
2546
- // let tc = EvenHubProto.textContainerProperty(
2547
- // x: 0, y: 0, width: 576, height: 288,
2548
- // borderWidth: 0, borderColor: 0, borderRadius: 0,
2549
- // paddingLength: 4, containerID: textContainerID,
2550
- // containerName: "text-main2", isEventCapture: true,
2551
- // content: "test-dbg1"
2552
- // )
2952
+ func dbg2() {
2953
+ // compassRunning.toggle()
2954
+ // Bridge.log("G2: dbg2() \(compassRunning ? "start" : "stop") compass")
2955
+ // if compassRunning {
2956
+ // startCompass()
2957
+ // } else {
2958
+ // stopCompass()
2959
+ // }
2960
+ }
2553
2961
 
2554
- // let msg: Data
2555
- // Bridge.log("G2: dbg2 - sending createPageMessage()")
2556
- // msg = EvenHubProto.createPageMessage(
2557
- // textContainers: [tc], magicRandom: sendManager.nextMagicRandom(),
2558
- // appId: nil)
2962
+ /// Start a navigation session so the glasses stream compass heading via
2963
+ /// OS_NOTIFY_COMPASS_CHANGED surfaced as `CompassHeadingEvent { heading: 0…359 }`
2964
+ /// in handleNavigationResponse.
2965
+ ///
2966
+ /// If the magnetometer needs calibration, the glasses emit
2967
+ /// OS_NOTIFY_COMPASS_CALIBRATE_STRAT (→ `CompassCalibrationEvent {status:"start"}`);
2968
+ /// the wearer should look around until `…{status:"complete"}`.
2969
+ func startCompass() {
2970
+ var w = ProtobufWriter()
2971
+ w.writeInt32Field(1, NavigationCmd.appRequestStartUp.rawValue) // cmd
2972
+ w.writeInt32Field(2, sendManager.nextMagicRandom()) // magicRandom
2973
+ sendNavigationCommand(w.data)
2974
+ }
2559
2975
 
2560
- // sendEvenHubCommand(msg)
2976
+ /// Stop the navigation/compass session (ends heading streaming).
2977
+ func stopCompass() {
2978
+ var w = ProtobufWriter()
2979
+ w.writeInt32Field(1, NavigationCmd.appRequestExit.rawValue)
2980
+ w.writeInt32Field(2, sendManager.nextMagicRandom())
2981
+ sendNavigationCommand(w.data)
2982
+ }
2983
+
2984
+ func setImuEnabled(_ enabled: Bool) async {
2985
+ await setImuEnabled(enabled, reportFrq: EvenHubProto.imuPaceP100)
2986
+ }
2987
+
2988
+ /// Enable or disable IMU motion reporting on the glasses.
2989
+ ///
2990
+ /// When enabled, the glasses continuously push `IMU_Report_Data { x, y, z }` (32-bit
2991
+ /// floats, gravity-normalized) via the EvenHub notify path; these surface in
2992
+ /// `handleTouchEvent` as a Sys_ItemEvent with `eventType == IMU_DATA_REPORT (8)` and
2993
+ /// are emitted through `Bridge.sendAccelEvent` (a single accelerometer reading;
2994
+ /// a richer combined IMU event covering gyro + magnetometer is future work).
2995
+ ///
2996
+ /// - Parameters:
2997
+ /// - enabled: `true` to start streaming, `false` to stop.
2998
+ /// - reportFrq: ImuReportPace pacing code (100…1000, step 100 — protocol codes, not
2999
+ /// Hz). Ignored when disabling.
3000
+ func setImuEnabled(_ enabled: Bool, reportFrq: Int32 = EvenHubProto.imuPaceP100) async {
3001
+ Bridge.log("G2: setImuEnabled(\(enabled), frq=\(reportFrq))")
3002
+
3003
+ // IMU requires an active EvenHub page (same prerequisite as the mic).
3004
+ if enabled && !pageCreated {
3005
+ await rebuildState()
3006
+ }
2561
3007
 
2562
- // // update the text
2563
- // Bridge.log("G2: sendTextWall() - updating text container")
2564
- // updateText("test2")
2565
- let currentDepth = DeviceStore.shared.get("bluetooth", "dashboard_depth") as? Int ?? 0
2566
- setDashboardDepthOnly(currentDepth)
3008
+ let send = { [weak self] in
3009
+ guard let self = self else { return }
3010
+ let msg = EvenHubProto.imuControlMessage(
3011
+ enable: enabled, reportFrq: reportFrq,
3012
+ magicRandom: self.sendManager.nextMagicRandom()
3013
+ )
3014
+ self.sendEvenHubCommand(msg)
3015
+ }
3016
+
3017
+ // If we just asked for a page, give it a moment to be created first.
3018
+ if enabled, !pageCreated {
3019
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.5, execute: send)
3020
+ } else {
3021
+ send()
3022
+ }
2567
3023
  }
2568
3024
 
2569
3025
  // MARK: - SGCManager: Device Control
@@ -2592,10 +3048,41 @@ class G2: NSObject, SGCManager {
2592
3048
  requestDeviceInfo()
2593
3049
  }
2594
3050
 
3051
+ /// Reorder the dashboard widgets so the calendar (Schedule) widget appears first.
3052
+ ///
3053
+ /// Sends a Dashboard_Receive (service 0x01) display-settings push with
3054
+ /// `widgetDisplayOrder` led by WidgetType 3 (Schedule). The remaining widgets
3055
+ /// keep their default relative order.
3056
+ /// WidgetType: 1=News, 2=Stock, 3=Schedule, 4=Quicklist, 5=Health
3057
+ func setCalendarWidgetFirst() {
3058
+ // Schedule (calendar) first, then the rest in their default order.
3059
+ let widgetOrder: [UInt8] = [3, 1, 2, 4, 5]
3060
+
3061
+ var dashDisplayW = ProtobufWriter()
3062
+ dashDisplayW.writeInt32Field(1, 4) // displayMode
3063
+ dashDisplayW.writeInt32Field(2, 3) // statusDisplayCount
3064
+ dashDisplayW.writeMessageField(3, Data([1, 2, 3])) // statusDisplayOrder
3065
+ dashDisplayW.writeInt32Field(4, Int32(widgetOrder.count)) // widgetDisplayCount
3066
+ dashDisplayW.writeMessageField(5, Data(widgetOrder)) // widgetDisplayOrder: Schedule first
3067
+ dashDisplayW.writeInt32Field(6, dashboardHalfDayFormat()) // halfDayFormat
3068
+ dashDisplayW.writeInt32Field(7, dashboardTemperatureUnit()) // temperatureUnit
3069
+
3070
+ var dashRecvW = ProtobufWriter()
3071
+ dashRecvW.writeMessageField(2, dashDisplayW.data)
3072
+
3073
+ var dashPkgW = ProtobufWriter()
3074
+ dashPkgW.writeInt32Field(1, 2) // Dashboard_Receive
3075
+ dashPkgW.writeInt32Field(2, sendManager.nextMagicRandom())
3076
+ dashPkgW.writeMessageField(4, dashRecvW.data)
3077
+ sendDashboardCommand(dashPkgW.data)
3078
+
3079
+ Bridge.log("G2: setCalendarWidgetFirst — widgetDisplayOrder \(widgetOrder)")
3080
+ }
3081
+
2595
3082
  func setDashboardMenu(_ items: [[String: Any]]) {
2596
3083
  let menuItems = items.compactMap { dict -> MenuProto.MenuItem? in
2597
3084
  guard let name = dict["name"] as? String,
2598
- let packageName = dict["packageName"] as? String
3085
+ let packageName = dict["packageName"] as? String
2599
3086
  else { return nil }
2600
3087
  let running = dict["running"] as? Bool ?? false
2601
3088
  return MenuProto.MenuItem(packageName: packageName, name: name, running: running)
@@ -2630,6 +3117,14 @@ class G2: NSObject, SGCManager {
2630
3117
  // TODO: Implement via dev_settings
2631
3118
  }
2632
3119
 
3120
+ /// Push the current time to the glasses. Useful after DST transitions,
3121
+ /// time-zone travel, or a long sleep where the glasses' clock has drifted.
3122
+ func syncTime() {
3123
+ Bridge.log("G2: syncTime()")
3124
+ let msg = DevSettingsProto.timeSync(magicRandom: sendManager.nextMagicRandom())
3125
+ sendDevSettingsCommand(msg, left: true, right: true)
3126
+ }
3127
+
2633
3128
  func sendRgbLedControl(
2634
3129
  requestId _: String, packageName _: String?, action _: String, color _: String?,
2635
3130
  onDurationMs _: Int, offDurationMs _: Int, count _: Int
@@ -2647,7 +3142,8 @@ class G2: NSObject, SGCManager {
2647
3142
 
2648
3143
  func requestPhoto(
2649
3144
  _: String, appId _: String, size _: String?, webhookUrl _: String?, authToken _: String?,
2650
- compress _: String?, flash _: Bool, sound _: Bool, exposureTimeNs _: Double?, iso _: Int?
3145
+ compress _: String?, flash _: Bool, save _: Bool, sound _: Bool, exposureTimeNs _: Double?,
3146
+ iso _: Int?
2651
3147
  ) {}
2652
3148
  func startVideoRecording(requestId _: String, save _: Bool, flash _: Bool, sound _: Bool) {}
2653
3149
  func startStream(_: [String: Any]) {}
@@ -2748,7 +3244,7 @@ class G2: NSObject, SGCManager {
2748
3244
  centralManager!.scanForPeripherals(
2749
3245
  withServices: nil,
2750
3246
  options: [
2751
- CBCentralManagerScanOptionAllowDuplicatesKey: false,
3247
+ CBCentralManagerScanOptionAllowDuplicatesKey: false
2752
3248
  ]
2753
3249
  )
2754
3250
  return true
@@ -2766,7 +3262,7 @@ class G2: NSObject, SGCManager {
2766
3262
  }
2767
3263
 
2768
3264
  guard let leftUUID = leftGlassUUID(forSN: DEVICE_SEARCH_ID),
2769
- let rightUUID = rightGlassUUID(forSN: DEVICE_SEARCH_ID)
3265
+ let rightUUID = rightGlassUUID(forSN: DEVICE_SEARCH_ID)
2770
3266
  else { return false }
2771
3267
 
2772
3268
  let knownLeft = centralManager?.retrievePeripherals(withIdentifiers: [leftUUID])
@@ -2807,8 +3303,8 @@ class G2: NSObject, SGCManager {
2807
3303
  // a different service than our primary one, and retrieveConnectedPeripherals only
2808
3304
  // returns peripherals whose services match.
2809
3305
  let serviceUUIDs: [CBUUID] = [
2810
- G2BLE.SERVICE_UUID, // EvenHub: 00002760-...-0000
2811
- CBUUID(string: "6E400001-B5A3-F393-E0A9-E50E24DCCA9E"), // Nordic UART
3306
+ G2BLE.SERVICE_UUID, // EvenHub: 00002760-...-0000
3307
+ CBUUID(string: "6E400001-B5A3-F393-E0A9-E50E24DCCA9E"), // Nordic UART
2812
3308
  ]
2813
3309
  var devices: [CBPeripheral] = []
2814
3310
  for svc in serviceUUIDs {
@@ -2836,8 +3332,8 @@ class G2: NSObject, SGCManager {
2836
3332
  // Extract XX (the numeric ID between G2_ and _L_/_R_)
2837
3333
  let pattern = "G2_(\\d+)_"
2838
3334
  guard let regex = try? NSRegularExpression(pattern: pattern),
2839
- let match = regex.firstMatch(in: name, range: NSRange(name.startIndex..., in: name)),
2840
- let range = Range(match.range(at: 1), in: name)
3335
+ let match = regex.firstMatch(in: name, range: NSRange(name.startIndex..., in: name)),
3336
+ let range = Range(match.range(at: 1), in: name)
2841
3337
  else {
2842
3338
  return nil
2843
3339
  }
@@ -2846,7 +3342,7 @@ class G2: NSObject, SGCManager {
2846
3342
 
2847
3343
  // MARK: - Incoming Data Handling
2848
3344
 
2849
- private func handleNotifyData(_ data: Data, from peripheral: CBPeripheral) {
3345
+ private func handleNotifyData(_ data: Data, from peripheral: CBPeripheral) async {
2850
3346
  // Distinguish left vs right peripheral so multi-packet reassembly doesn't collide
2851
3347
  let sourceKey = peripheral === leftPeripheral ? "L" : "R"
2852
3348
  guard let result = receiveManager.handlePacket(data, sourceKey: sourceKey) else { return }
@@ -2867,16 +3363,79 @@ class G2: NSObject, SGCManager {
2867
3363
  case ServiceID.dashboard.rawValue:
2868
3364
  handleDashboardResponse(result.payload)
2869
3365
  case ServiceID.gestureCtrl.rawValue:
2870
- handleGestureCtrl(result.payload)
3366
+ await handleGestureCtrl(result.payload)
3367
+ case ServiceID.navigation.rawValue:
3368
+ handleNavigationResponse(result.payload)
3369
+ case ServiceID.evenAI.rawValue:
3370
+ handleEvenAIResponse(result.payload)
2871
3371
  case ServiceID.evenHubCtrl.rawValue:
2872
3372
  handleEvenHubCtrlResponse(result.payload)
2873
3373
  default:
2874
3374
  Bridge.log(
2875
- "G2: Unhandled service \(result.serviceId) (\(result.payload.count) bytes): \(result.payload.prefix(32).map { String(format: "%02X", $0) }.joined())"
3375
+ "G2: Unhandled service \(result.serviceId) (\(result.payload.count) bytes): \(result.payload.map { String(format: "%02X", $0) }.joined())"
2876
3376
  )
2877
3377
  }
2878
3378
  }
2879
3379
 
3380
+ /// EvenAI service (0x07). Logs the decoded EvenAIDataPackage so we can read the
3381
+ /// CONFIG (Hey Even) echo: commandId=10 (CONFIG), config sub-message in field 13.
3382
+ private func handleEvenAIResponse(_ payload: Data) {
3383
+ var reader = ProtobufReader(payload)
3384
+ let fields = reader.parseFields()
3385
+ let cmd = fields[1] as? Int32 ?? -1
3386
+ if cmd == 10, let configData = fields[13] as? Data {
3387
+ var cReader = ProtobufReader(configData)
3388
+ let cFields = cReader.parseFields()
3389
+ let voiceSwitch = cFields[1] as? Int32 ?? 0 // omitted = 0 = OFF
3390
+ Bridge.log(
3391
+ "G2: EvenAI CONFIG echo — voiceSwitch=\(voiceSwitch) (\(voiceSwitch == 1 ? "ON" : "OFF")) config=\(cFields)"
3392
+ )
3393
+ } else {
3394
+ Bridge.log(
3395
+ "G2: EvenAI cmd=\(cmd) fields=\(Array(fields.keys).sorted()) raw=\(payload.map { String(format: "%02X", $0) }.joined())"
3396
+ )
3397
+ }
3398
+ }
3399
+
3400
+ /// Navigation service (0x08).
3401
+ ///
3402
+ /// OS_NOTIFY_COMPASS_CHANGED (15) carries the magnetometer heading in
3403
+ /// compass_info_msg (field 10) → field 1, as whole degrees 0…359. (The proto names
3404
+ /// that field `compassIndex`, but on the notify path it's the live heading — verified
3405
+ /// on-device: values sweep 0–359 as the wearer turns.)
3406
+ private func handleNavigationResponse(_ payload: Data) {
3407
+ var reader = ProtobufReader(payload)
3408
+ let fields = reader.parseFields()
3409
+ guard let cmd = fields[1] as? Int32 else { return }
3410
+
3411
+ switch cmd {
3412
+ case NavigationCmd.osNotifyCompassChanged.rawValue:
3413
+ guard let compassData = fields[10] as? Data else { return }
3414
+ var cReader = ProtobufReader(compassData)
3415
+ let cFields = cReader.parseFields()
3416
+ guard let heading = cFields[1] as? Int32 else { return }
3417
+ // Heading in degrees, 0…359.
3418
+ Bridge.log("G2: compass heading=\(heading)°")
3419
+ Bridge.sendTypedMessage(
3420
+ "CompassHeadingEvent",
3421
+ body: [
3422
+ "heading": Int(heading),
3423
+ "timestamp": Int64(Date().timeIntervalSince1970 * 1000),
3424
+ ])
3425
+
3426
+ case NavigationCmd.osNotifyCompassCalibrateStart.rawValue:
3427
+ Bridge.log("G2: compass calibration started — wearer should look around")
3428
+ Bridge.sendTypedMessage("CompassCalibrationEvent", body: ["status": "start"])
3429
+
3430
+ case NavigationCmd.osNotifyCompassCalibrateComplete.rawValue:
3431
+ Bridge.log("G2: compass calibration complete")
3432
+ Bridge.sendTypedMessage("CompassCalibrationEvent", body: ["status": "complete"])
3433
+
3434
+ default:
3435
+ break
3436
+ }
3437
+ }
3438
+
2880
3439
  private func handleEvenHubResponse(_ payload: Data) {
2881
3440
  // Parse evenhub_main_msg_ctx: field 1 = Cmd (varint), field 13 = DevEvent (submessage)
2882
3441
  var reader = ProtobufReader(payload)
@@ -2931,6 +3490,16 @@ class G2: NSObject, SGCManager {
2931
3490
  }
2932
3491
  }
2933
3492
  } else {
3493
+
3494
+ // response codes:
3495
+ let timestamp = Int64(Date().timeIntervalSince1970 * 1000)
3496
+ if lastEvenHubResponseTimestamp != nil
3497
+ && timestamp - lastEvenHubResponseTimestamp! < 100
3498
+ {
3499
+ return
3500
+ }
3501
+ lastEvenHubResponseTimestamp = timestamp
3502
+
2934
3503
  // Log unhandled EvenHub commands (helps debug menu selection and stock dashboard interactions)
2935
3504
  // Bridge.log(
2936
3505
  // "G2: EvenHub response cmd=\(cmdValue), \(payload.count) bytes, fields=\(Array(fields.keys).sorted())"
@@ -2949,15 +3518,16 @@ class G2: NSObject, SGCManager {
2949
3518
  Bridge.log(
2950
3519
  "G2: WARN: Glasses shutdown our EvenHub page — resetting page state"
2951
3520
  )
2952
- startupPageCreated = false
2953
3521
  pageCreated = false
2954
- pageHasTextContainer = false
2955
- currentTextContent = ""
2956
3522
  }
2957
3523
  }
2958
3524
  if let errorCode = resFields[8] as? Int32 {
2959
3525
  // ImgResCmd has ErrorCode in field 8
2960
- Bridge.log("G2: EvenHub ImgRes errorCode=\(errorCode)")
3526
+ if errorCode == 4 {
3527
+ Bridge.log("G2: img_success")
3528
+ } else {
3529
+ Bridge.log("G2: EvenHub ImgRes errorCode=\(errorCode)")
3530
+ }
2961
3531
  }
2962
3532
  }
2963
3533
  }
@@ -2965,10 +3535,7 @@ class G2: NSObject, SGCManager {
2965
3535
  // If glasses sent a shutdown (cmd=9/10), our page is gone — reset state
2966
3536
  if cmdValue == 9 || cmdValue == 10 {
2967
3537
  Bridge.log("G2: ERROR: Glasses shutdown our EvenHub page — resetting page state")
2968
- startupPageCreated = false
2969
3538
  pageCreated = false
2970
- pageHasTextContainer = false
2971
- currentTextContent = ""
2972
3539
  }
2973
3540
  }
2974
3541
  }
@@ -2997,6 +3564,37 @@ class G2: NSObject, SGCManager {
2997
3564
  }
2998
3565
  }
2999
3566
 
3567
+ /// Parse an IMU_Report_Data sub-message: fields 1/2/3 = x/y/z as 32-bit floats
3568
+ /// (wire type 5). `ProtobufReader.parseFields()` skips wire-type-5 fields, so this
3569
+ /// walks the bytes manually.
3570
+ private func parseImuReportData(_ data: Data) -> (x: Float, y: Float, z: Float)? {
3571
+ var x: Float?
3572
+ var y: Float?
3573
+ var z: Float?
3574
+ var i = data.startIndex
3575
+ while i < data.endIndex {
3576
+ let tag = data[i]
3577
+ i = data.index(after: i)
3578
+ let fieldNum = Int(tag >> 3)
3579
+ let wireType = Int(tag & 0x07)
3580
+ guard wireType == 5, data.distance(from: i, to: data.endIndex) >= 4 else { break }
3581
+ var bits: UInt32 = 0
3582
+ for b in 0..<4 {
3583
+ bits |= UInt32(data[data.index(i, offsetBy: b)]) << (8 * b) // little-endian
3584
+ }
3585
+ i = data.index(i, offsetBy: 4)
3586
+ let value = Float(bitPattern: bits)
3587
+ switch fieldNum {
3588
+ case 1: x = value
3589
+ case 2: y = value
3590
+ case 3: z = value
3591
+ default: break
3592
+ }
3593
+ }
3594
+ guard let x = x, let y = y, let z = z else { return nil }
3595
+ return (x, y, z)
3596
+ }
3597
+
3000
3598
  private func handleTouchEvent(_ devEventData: Data) {
3001
3599
  // Parse SendDeviceEvent: field 1=ListEvent, field 2=TextEvent, field 3=SysEvent
3002
3600
  var reader = ProtobufReader(devEventData)
@@ -3015,6 +3613,19 @@ class G2: NSObject, SGCManager {
3015
3613
  if let sysData = fields[3] as? Data {
3016
3614
  var sysReader = ProtobufReader(sysData)
3017
3615
  let sysFields = sysReader.parseFields()
3616
+
3617
+ // IMU data report: eventType == IMU_DATA_REPORT (8), imuData in field 3
3618
+ // (IMU_Report_Data { x, y, z } as 32-bit floats). Handle and return before
3619
+ // the gesture-mapping path.
3620
+ if (sysFields[1] as? Int32) == OsEventType.imuDataReport.rawValue,
3621
+ let imuData = sysFields[3] as? Data,
3622
+ let imu = parseImuReportData(imuData)
3623
+ {
3624
+ Bridge.log("G2: IMU data report: \(imu.x), \(imu.y), \(imu.z)")
3625
+ Bridge.sendAccelEvent(x: imu.x, y: imu.y, z: imu.z, timestamp: timestamp)
3626
+ return
3627
+ }
3628
+
3018
3629
  var eventType: OsEventType? = nil
3019
3630
  var eventSource: Int32? = nil
3020
3631
  if let normalType = sysFields[1] as? Int32 {
@@ -3054,7 +3665,8 @@ class G2: NSObject, SGCManager {
3054
3665
  // trigger dashboard:
3055
3666
  let isHeadUp = DeviceStore.shared.get("glasses", "headUp") as? Bool ?? false
3056
3667
 
3057
- let useNativeDashboard = DeviceStore.shared.get("bluetooth", "use_native_dashboard") as? Bool ?? false
3668
+ let useNativeDashboard =
3669
+ DeviceStore.shared.get("bluetooth", "use_native_dashboard") as? Bool ?? false
3058
3670
  if useNativeDashboard {
3059
3671
  showDashboard()
3060
3672
  } else {
@@ -3073,7 +3685,6 @@ class G2: NSObject, SGCManager {
3073
3685
 
3074
3686
  // toggle head up:
3075
3687
  // DeviceStore.shared.apply("glasses", "headUp", true)
3076
- // runDashboardSequence()
3077
3688
  }
3078
3689
 
3079
3690
  // if eventType == .foregroundEnter {
@@ -3087,14 +3698,8 @@ class G2: NSObject, SGCManager {
3087
3698
  // System exit: glasses killed our EvenHub page (user opened menu or another app)
3088
3699
  // Reset page state and re-create the page to reclaim EvenHub focus
3089
3700
  if eventType == .systemExit || eventType == .abnormalExit {
3090
- let savedText = currentTextContent
3091
- let savedBitmap = currentBitmapBase64
3092
3701
  // Bridge.log("G2: System exit detected")
3093
- startupPageCreated = false
3094
3702
  pageCreated = false
3095
- pageHasTextContainer = false
3096
- currentTextContent = ""
3097
- currentBitmapBase64 = ""
3098
3703
  // Firmware kills the mic on system exit; re-arm it if it should be on
3099
3704
  DeviceStore.shared.apply("glasses", "micEnabled", false)
3100
3705
  DeviceManager.shared.updateMicState()
@@ -3116,7 +3721,7 @@ class G2: NSObject, SGCManager {
3116
3721
  var textReader = ProtobufReader(textData)
3117
3722
  let textFields = textReader.parseFields()
3118
3723
  if let eventTypeRaw = textFields[3] as? Int32,
3119
- let eventType = OsEventType(rawValue: eventTypeRaw)
3724
+ let eventType = OsEventType(rawValue: eventTypeRaw)
3120
3725
  {
3121
3726
  guard let gestureName = mapEventTypeToGesture(eventType) else {
3122
3727
  Bridge.log("G2: no gesture mapping for \(eventType) \(textFields)")
@@ -3157,7 +3762,8 @@ class G2: NSObject, SGCManager {
3157
3762
  case .foregroundEnter: return "foreground_enter"
3158
3763
  case .foregroundExit: return "foreground_exit"
3159
3764
  case .systemExit: return "system_exit"
3160
- case .abnormalExit: return nil // don't report abnormal exits as gestures
3765
+ case .imuDataReport: return nil
3766
+ case .abnormalExit: return nil // don't report abnormal exits as gestures
3161
3767
  }
3162
3768
  }
3163
3769
 
@@ -3180,15 +3786,15 @@ class G2: NSObject, SGCManager {
3180
3786
 
3181
3787
  // if the data is just a heartbeat, ignore it:
3182
3788
  if let cmdValue = fields[1] as? Int32,
3183
- cmdValue == DevCfgCommandId.baseConnHeartBeat.rawValue
3789
+ cmdValue == DevCfgCommandId.baseConnHeartBeat.rawValue
3184
3790
  {
3185
3791
  return
3186
3792
  }
3187
3793
  // Bridge.log("G2: DevSettings response cmdValue=\(cmdValue)")
3188
3794
 
3189
- Bridge.log(
3190
- "G2: DevSettings response: \(data.prefix(32).map { String(format: "%02X", $0) }.joined(separator: ":"))"
3191
- )
3795
+ // Bridge.log(
3796
+ // "G2: DevSettings response: \(data.prefix(32).map { String(format: "%02X", $0) }.joined(separator: ":"))"
3797
+ // )
3192
3798
 
3193
3799
  // RING_CONNECT_INFO response (cmd 6)
3194
3800
  if cmdValue == DevCfgCommandId.ringConnectInfo.rawValue {
@@ -3204,7 +3810,7 @@ class G2: NSObject, SGCManager {
3204
3810
  // Bridge.log("G2: Ring connection status: connStat=\(connStat)")
3205
3811
 
3206
3812
  // Bridge.log("G2: RingConnectInfo: \(fields)")
3207
- if let ringData = fields[5] as? Data { // field 5 = ringInfo
3813
+ if let ringData = fields[5] as? Data { // field 5 = ringInfo
3208
3814
  var ringReader = ProtobufReader(ringData)
3209
3815
  let ringFields = ringReader.parseFields()
3210
3816
 
@@ -3231,13 +3837,13 @@ class G2: NSObject, SGCManager {
3231
3837
  // DeviceStore.shared.apply("glasses", "controllerSearching", true)
3232
3838
  // }
3233
3839
 
3234
- if let ringData = fields[5] as? Data { // field 5 = ringInfo
3840
+ if let ringData = fields[5] as? Data { // field 5 = ringInfo
3235
3841
  var ringReader = ProtobufReader(ringData)
3236
3842
  let ringFields = ringReader.parseFields()
3237
- let connStatus = ringFields[4] as? Int32 ?? -1 // field 4 = connStatus
3238
- Bridge.log(
3239
- "G2: Ring connection status: connStatus?=\(connStatus))"
3240
- )
3843
+ let connStatus = ringFields[4] as? Int32 ?? -1 // field 4 = connStatus
3844
+ // Bridge.log(
3845
+ // "G2: Ring connection status: connStatus?=\(connStatus))"
3846
+ // )
3241
3847
 
3242
3848
  if connStatus == 22 {
3243
3849
  Bridge.log("G2: Ring disconnected")
@@ -3338,13 +3944,13 @@ class G2: NSObject, SGCManager {
3338
3944
 
3339
3945
  // Software versions
3340
3946
  if let leftVer = fields[5] as? Data,
3341
- let leftVersion = String(data: leftVer, encoding: .utf8)
3947
+ let leftVersion = String(data: leftVer, encoding: .utf8)
3342
3948
  {
3343
3949
  // Bridge.log("G2: Left firmware: \(leftVersion)")
3344
3950
  DeviceStore.shared.apply("glasses", "leftFirmwareVersion", leftVersion)
3345
3951
  }
3346
3952
  if let rightVer = fields[6] as? Data,
3347
- let rightVersion = String(data: rightVer, encoding: .utf8)
3953
+ let rightVersion = String(data: rightVer, encoding: .utf8)
3348
3954
  {
3349
3955
  // Bridge.log("G2: Right firmware: \(rightVersion)")
3350
3956
  DeviceStore.shared.apply("glasses", "rightFirmwareVersion", rightVersion)
@@ -3363,7 +3969,7 @@ class G2: NSObject, SGCManager {
3363
3969
 
3364
3970
  private func handleDashboardResponse(_ payload: Data) {
3365
3971
  Bridge.log(
3366
- "G2: dashboard response: \(payload.prefix(32).map { String(format: "%02X", $0) }.joined())"
3972
+ "G2: dashboard response: \(payload.map { String(format: "%02X", $0) }.joined())"
3367
3973
  )
3368
3974
  var reader = ProtobufReader(payload)
3369
3975
  let fields = reader.parseFields()
@@ -3382,13 +3988,13 @@ class G2: NSObject, SGCManager {
3382
3988
  // AppRespondToDashboard: field1=packageId, field2=flag (0=success)
3383
3989
  if cmd == 3 {
3384
3990
  var appRespW = ProtobufWriter()
3385
- appRespW.writeInt32Field(1, packageId) // packageId
3386
- appRespW.writeInt32Field(2, 0) // flag = APP_RECEIVED_SUCCESS
3991
+ appRespW.writeInt32Field(1, packageId) // packageId
3992
+ appRespW.writeInt32Field(2, 0) // flag = APP_RECEIVED_SUCCESS
3387
3993
 
3388
3994
  var pkgW = ProtobufWriter()
3389
- pkgW.writeInt32Field(1, 4) // commandId = APP_RECEIVE
3995
+ pkgW.writeInt32Field(1, 4) // commandId = APP_RECEIVE
3390
3996
  pkgW.writeInt32Field(2, magicRandom)
3391
- pkgW.writeMessageField(5, appRespW.data) // field5 = appRespond
3997
+ pkgW.writeMessageField(5, appRespW.data) // field5 = appRespond
3392
3998
  sendDashboardCommand(pkgW.data)
3393
3999
  }
3394
4000
  }
@@ -3400,7 +4006,7 @@ class G2: NSObject, SGCManager {
3400
4006
  )
3401
4007
  }
3402
4008
 
3403
- private func handleGestureCtrl(_ data: Data) {
4009
+ private func handleGestureCtrl(_ data: Data) async {
3404
4010
  // gesture_ctrl (service 0x0D): foreground lifecycle signals from glasses
3405
4011
  // (informational only — log if needed for debugging)
3406
4012
  // log first few bytes of the response:
@@ -3409,31 +4015,43 @@ class G2: NSObject, SGCManager {
3409
4015
  // )
3410
4016
  // Bridge.log("G2: gesture_ctrl response:")
3411
4017
 
4018
+ // Dedup: L and R peripherals both deliver this event, so debounce or
4019
+ let timestamp = Int64(Date().timeIntervalSince1970 * 1000)
4020
+ if lastGestureCtrlTimestamp != nil && timestamp - lastGestureCtrlTimestamp! < 500 {
4021
+ // Bridge.log("G2: gesture_ctrl dedup")
4022
+ return
4023
+ }
4024
+ lastGestureCtrlTimestamp = timestamp
4025
+
3412
4026
  // if we got 08011A00 that means we closed the dashboard, which means the mic is probably dead,
3413
4027
  // so we need to revive it:
3414
4028
  if data == Data([0x08, 0x01, 0x1A, 0x00]) {
3415
4029
  Bridge.log("G2: dashboard closed / shutdown - dashboardShowing=\(dashboardShowing)")
3416
- let useNativeDashboard = DeviceStore.shared.get("bluetooth", "use_native_dashboard") as? Bool ?? false
4030
+ let useNativeDashboard =
4031
+ DeviceStore.shared.get("bluetooth", "use_native_dashboard") as? Bool ?? false
3417
4032
  if !useNativeDashboard {
3418
- // make sure the container exists:
3419
- DeviceManager.shared.sendCurrentState()
3420
- // re-send mic on / if it's enabled:
3421
- let micEnabled = DeviceStore.shared.get("glasses", "micEnabled") as? Bool ?? false
3422
- if micEnabled {
3423
- restartMic()
4033
+ dashboardShowing = 0
4034
+ // rebuild state:
4035
+ Task {
4036
+ await rebuildState()
4037
+ // set the mic back on if it should be on
4038
+ let micEnabled =
4039
+ DeviceStore.shared.get("glasses", "micEnabled") as? Bool ?? false
4040
+ if micEnabled {
4041
+ restartMic()
4042
+ }
3424
4043
  }
3425
- // reset the text container (different from clearDisplay())
3426
- // sendTextWall(" ")
3427
- // createPageWithText(" ")
4044
+ return
3428
4045
  } else {
3429
4046
  // if we aren't trying to show the dashboard
3430
4047
  // then we need to turn the mic back on and display the mentra main page:
3431
4048
  if dashboardShowing <= 1 {
3432
4049
  dashboardShowing = 0
3433
- // make sure the container exists:
3434
- DeviceManager.shared.sendCurrentState()
4050
+ // rebuild state:
4051
+ await rebuildState()
3435
4052
  // set the mic back on if it should be on
3436
- let micEnabled = DeviceStore.shared.get("glasses", "micEnabled") as? Bool ?? false
4053
+ let micEnabled =
4054
+ DeviceStore.shared.get("glasses", "micEnabled") as? Bool ?? false
3437
4055
  if micEnabled {
3438
4056
  restartMic()
3439
4057
  }
@@ -3466,7 +4084,7 @@ class G2: NSObject, SGCManager {
3466
4084
 
3467
4085
  private var lastAudioFrame: Data?
3468
4086
 
3469
- private func handleAudioData(_ data: Data) {
4087
+ private func handleAudioData(_ data: Data) async {
3470
4088
  // G2 audio arrives on AUDIO_NOTIFY characteristic
3471
4089
  // Format: ~200+ byte chunks, use first 200 bytes, split into 40-byte LC3 frames
3472
4090
  // Each frame: LC3, 16kHz, mono, 10ms, 40 bytes
@@ -3496,7 +4114,7 @@ func extractSN(from data: Data) -> String? {
3496
4114
  // where the SN string starts.
3497
4115
 
3498
4116
  // Skip "ER" prefix (2 bytes), read 14 bytes of SN
3499
- let snData = data[2 ..< 16]
4117
+ let snData = data[2..<16]
3500
4118
  return String(data: snData, encoding: .ascii)?
3501
4119
  .replacingOccurrences(
3502
4120
  of: "[\\x00-\\x1F\\x7F]", with: "", options: .regularExpression
@@ -3508,7 +4126,7 @@ func extractSN(from data: Data) -> String? {
3508
4126
  /// Returns "AA:BB:CC:DD:EE:FF" (big-endian, colon-separated).
3509
4127
  func extractMac(from data: Data) -> String? {
3510
4128
  guard data.count >= 22 else { return nil }
3511
- let macLE = data[16 ..< 22]
4129
+ let macLE = data[16..<22]
3512
4130
  return macLE.reversed().map { String(format: "%02X", $0) }.joined(separator: ":")
3513
4131
  }
3514
4132
 
@@ -3532,13 +4150,13 @@ extension G2: CBCentralManagerDelegate {
3532
4150
  ) {
3533
4151
  guard
3534
4152
  let name = peripheral.name ?? advertisementData[CBAdvertisementDataLocalNameKey]
3535
- as? String
4153
+ as? String
3536
4154
  else { return }
3537
4155
 
3538
4156
  // G2 glasses have "Even" prefix and "G2" in name, with _L_ or _R_ for side
3539
4157
  guard name.contains("G2") else { return }
3540
4158
  guard let mfgData = advertisementData[CBAdvertisementDataManufacturerDataKey] as? Data,
3541
- mfgData.count >= 16
4159
+ mfgData.count >= 16
3542
4160
  else { return }
3543
4161
 
3544
4162
  DispatchQueue.main.async { [weak self] in
@@ -3550,7 +4168,9 @@ extension G2: CBCentralManagerDelegate {
3550
4168
  }
3551
4169
  // sn = "S200LACA040040"
3552
4170
  let mfgHex = mfgData.map { String(format: "%02X", $0) }.joined(separator: " ")
3553
- Bridge.log("G2: Discovered: \(name) (SN: \(serialNumber)) mfgData[\(mfgData.count)]: \(mfgHex)")
4171
+ Bridge.log(
4172
+ "G2: Discovered: \(name) (SN: \(serialNumber)) mfgData[\(mfgData.count)]: \(mfgHex)"
4173
+ )
3554
4174
  self.deviceNameToSerialNumber[name] = serialNumber
3555
4175
 
3556
4176
  // Save MAC per side; ring's advStart needs the left lens MAC.
@@ -3613,7 +4233,9 @@ extension G2: CBCentralManagerDelegate {
3613
4233
  self.setRightGlassUUID(peripheral.identifier, forSN: sn)
3614
4234
  }
3615
4235
  } else {
3616
- Bridge.log("G2: didConnect — no SN for \(peripheral.name ?? "unknown"), skipping UUID save")
4236
+ Bridge.log(
4237
+ "G2: didConnect — no SN for \(peripheral.name ?? "unknown"), skipping UUID save"
4238
+ )
3617
4239
  }
3618
4240
 
3619
4241
  // Discover services - scan for all since we need to find the EvenHub characteristics
@@ -3647,7 +4269,7 @@ extension G2: CBCentralManagerDelegate {
3647
4269
 
3648
4270
  self.startupPageCreated = false
3649
4271
  self.pageCreated = false
3650
- self.pageHasTextContainer = false
4272
+ self.dashboardShowing = 0
3651
4273
  DeviceStore.shared.apply("glasses", "connected", false)
3652
4274
  DeviceStore.shared.apply("glasses", "fullyBooted", false)
3653
4275
 
@@ -3662,7 +4284,9 @@ extension G2: CBCentralManagerDelegate {
3662
4284
  guard let self else { return false }
3663
4285
 
3664
4286
  // Check if already connected
3665
- if await MainActor.run(body: { DeviceStore.shared.get("glasses", "fullyBooted") as? Bool ?? false }) {
4287
+ if await MainActor.run(body: {
4288
+ DeviceStore.shared.get("glasses", "fullyBooted") as? Bool ?? false
4289
+ }) {
3666
4290
  Bridge.log("G2: Already connected, stopping reconnection")
3667
4291
  return true
3668
4292
  }
@@ -3754,7 +4378,7 @@ extension G2: CBPeripheralDelegate {
3754
4378
  if self.leftInitialized && self.rightInitialized && !self.authStarted {
3755
4379
  self.authStarted = true
3756
4380
  Bridge.log("G2: Both sides initialized, starting auth sequence")
3757
- self.runAuthSequence()
4381
+ Task { await self.runAuthSequence() }
3758
4382
  }
3759
4383
  }
3760
4384
  }
@@ -3765,15 +4389,15 @@ extension G2: CBPeripheralDelegate {
3765
4389
  ) {
3766
4390
  guard let data = characteristic.value, error == nil else { return }
3767
4391
 
3768
- DispatchQueue.main.async { [weak self] in
4392
+ Task { @MainActor [weak self] in
3769
4393
  guard let self = self else { return }
3770
4394
 
3771
4395
  if characteristic.uuid == G2BLE.AUDIO_NOTIFY {
3772
4396
  // Audio data - forward to mic system
3773
- self.handleAudioData(data)
4397
+ await self.handleAudioData(data)
3774
4398
  } else if characteristic.uuid == G2BLE.CHAR_NOTIFY {
3775
4399
  // Protocol data
3776
- self.handleNotifyData(data, from: peripheral)
4400
+ await self.handleNotifyData(data, from: peripheral)
3777
4401
  }
3778
4402
  }
3779
4403
  }