@mentra/bluetooth-sdk 0.1.10 → 0.1.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. package/README.md +1 -1
  2. package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkModule.kt +39 -16
  3. package/android/src/main/java/com/mentra/bluetoothsdk/Bridge.kt +14 -0
  4. package/android/src/main/java/com/mentra/bluetoothsdk/DeviceManager.kt +22 -3
  5. package/android/src/main/java/com/mentra/bluetoothsdk/DeviceStore.kt +8 -0
  6. package/android/src/main/java/com/mentra/bluetoothsdk/camera/CameraModels.kt +2 -1
  7. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/G2.kt +829 -643
  8. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraNex.kt +40 -64
  9. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/SGCManager.kt +11 -1
  10. package/build/BluetoothSdk.types.d.ts +12 -1
  11. package/build/BluetoothSdk.types.d.ts.map +1 -1
  12. package/build/BluetoothSdk.types.js.map +1 -1
  13. package/build/_private/BluetoothSdkModule.d.ts +1 -0
  14. package/build/_private/BluetoothSdkModule.d.ts.map +1 -1
  15. package/build/_private/BluetoothSdkModule.js +14 -1
  16. package/build/_private/BluetoothSdkModule.js.map +1 -1
  17. package/build/index.d.ts +1 -1
  18. package/build/index.d.ts.map +1 -1
  19. package/build/index.js +2 -0
  20. package/build/index.js.map +1 -1
  21. package/ios/BluetoothSdkModule.swift +3 -0
  22. package/ios/Source/Bridge.swift +10 -0
  23. package/ios/Source/DeviceManager.swift +24 -21
  24. package/ios/Source/DeviceStore.swift +8 -2
  25. package/ios/Source/camera/CameraModels.swift +1 -0
  26. package/ios/Source/sgcs/G1.swift +3 -3
  27. package/ios/Source/sgcs/G2.swift +1022 -640
  28. package/ios/Source/sgcs/Mach1.swift +2 -2
  29. package/ios/Source/sgcs/MentraLive.swift +2 -2
  30. package/ios/Source/sgcs/MentraNex.swift +215 -85
  31. package/ios/Source/sgcs/SGCManager.swift +13 -4
  32. package/ios/Source/sgcs/Simulated.swift +2 -2
  33. package/package.json +1 -1
  34. package/src/BluetoothSdk.types.ts +13 -1
  35. package/src/_private/BluetoothSdkModule.ts +22 -3
  36. package/src/index.ts +3 -0
@@ -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,32 +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 notification = 4 // 0x04 - UI_FOREGROUND_NOTIFICATION_ID
57
- case evenAI = 7 // 0x07 - UI_FOREGROUND_EVEN_AI_ID
58
- case g2Setting = 9 // 0x09 - UI_SETTING_APP_ID
59
- case gestureCtrl = 13 // 0x0D - gesture_ctrl lifecycle signals
60
- case onboarding = 16 // 0x10 - UI_ONBOARDING_APP_ID
61
- case deviceSettings = 128 // 0x80 - UX_DEVICE_SETTINGS_APP_ID
62
- case evenHubCtrl = 129 // 0x81 - EvenHub CTRL channel (init/registration)
63
- 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
64
65
  }
65
66
 
66
67
  /// EvenHub command IDs from EvenHub.proto
67
68
  private enum EvenHubCmd: Int32 {
68
- case createStartupPage = 0 // APP_REQUEST_CREATE_STARTUP_PAGE_PACKET
69
- case updateImageRawData = 3 // APP_UPDATE_IMAGE_RAW_DATA_PACKET
70
- case updateTextData = 5 // APP_UPDATE_TEXT_DATA_PACKET
71
- case rebuildPage = 7 // APP_REQUEST_REBUILD_PAGE_PACKET
72
- case shutdownPage = 9 // APP_REQUEST_SHUTDOWN_PAGE_PACKET
73
- case heartbeat = 12 // APP_REQUEST_HEARTBEAT_PACKET
74
- 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
75
90
  }
76
91
 
77
92
  /// EvenHub response command IDs (from glasses → phone)
78
93
  private enum EvenHubResponseCmd: Int32 {
79
- 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
80
95
  }
81
96
 
82
97
  /// OsEventTypeList from EvenHub.proto
@@ -89,15 +104,16 @@ private enum OsEventType: Int32 {
89
104
  case foregroundExit = 5
90
105
  case abnormalExit = 6
91
106
  case systemExit = 7
107
+ case imuDataReport = 8 // IMU_DATA_REPORT — Sys_ItemEvent carries imuData
92
108
  }
93
109
 
94
110
  /// g2_settingCommandId from g2_setting.proto
95
111
  private enum G2SettingCommandId: Int32 {
96
112
  case none = 0
97
- case deviceReceiveInfo = 1 // Send settings TO glasses
98
- case deviceReceiveRequest = 2 // Request info FROM glasses
99
- case deviceSendToApp = 3 // Glasses sends info TO app
100
- 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
101
117
  }
102
118
 
103
119
  /// DevCfgCommandId from dev_config_protocol.proto
@@ -141,7 +157,7 @@ private struct ProtobufWriter {
141
157
  }
142
158
 
143
159
  mutating func writeInt32Field(_ fieldNumber: Int, _ value: Int32) {
144
- let tag = UInt64(fieldNumber << 3) | 0 // wire type 0 = varint
160
+ let tag = UInt64(fieldNumber << 3) | 0 // wire type 0 = varint
145
161
  writeVarint(tag)
146
162
  // protobuf int32 uses varint encoding; negative values use 10 bytes
147
163
  if value >= 0 {
@@ -152,13 +168,13 @@ private struct ProtobufWriter {
152
168
  }
153
169
 
154
170
  mutating func writeInt64Field(_ fieldNumber: Int, _ value: Int64) {
155
- let tag = UInt64(fieldNumber << 3) | 0 // wire type 0 = varint
171
+ let tag = UInt64(fieldNumber << 3) | 0 // wire type 0 = varint
156
172
  writeVarint(tag)
157
173
  writeVarint(UInt64(bitPattern: value))
158
174
  }
159
175
 
160
176
  mutating func writeStringField(_ fieldNumber: Int, _ value: String) {
161
- let tag = UInt64(fieldNumber << 3) | 2 // wire type 2 = length-delimited
177
+ let tag = UInt64(fieldNumber << 3) | 2 // wire type 2 = length-delimited
162
178
  writeVarint(tag)
163
179
  let utf8 = Array(value.utf8)
164
180
  writeVarint(UInt64(utf8.count))
@@ -166,7 +182,7 @@ private struct ProtobufWriter {
166
182
  }
167
183
 
168
184
  mutating func writeBytesField(_ fieldNumber: Int, _ value: Data) {
169
- let tag = UInt64(fieldNumber << 3) | 2 // wire type 2 = length-delimited
185
+ let tag = UInt64(fieldNumber << 3) | 2 // wire type 2 = length-delimited
170
186
  writeVarint(tag)
171
187
  writeVarint(UInt64(value.count))
172
188
  data.append(value)
@@ -228,7 +244,7 @@ private struct ProtobufReader {
228
244
  guard let len = readVarint() else { return nil }
229
245
  let length = Int(len)
230
246
  guard offset + length <= data.count else { return nil }
231
- let result = data[(data.startIndex + offset) ..< (data.startIndex + offset + length)]
247
+ let result = data[(data.startIndex + offset)..<(data.startIndex + offset + length)]
232
248
  offset += length
233
249
  return Data(result)
234
250
  }
@@ -241,10 +257,10 @@ private struct ProtobufReader {
241
257
  /// Skip a field value based on wire type
242
258
  mutating func skipField(wireType: Int) {
243
259
  switch wireType {
244
- case 0: _ = readVarint() // varint
245
- case 1: offset += 8 // 64-bit
246
- case 2: _ = readBytes() // length-delimited
247
- 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
248
264
  default: break
249
265
  }
250
266
  }
@@ -256,9 +272,9 @@ private struct ProtobufReader {
256
272
  while hasMore {
257
273
  guard let (fieldNum, wireType) = readTag() else { break }
258
274
  switch wireType {
259
- case 0: // varint
275
+ case 0: // varint
260
276
  if let v = readVarint() { fields[fieldNum] = Int32(truncatingIfNeeded: v) }
261
- case 2: // length-delimited (submessage or bytes or string)
277
+ case 2: // length-delimited (submessage or bytes or string)
262
278
  if let d = readBytes() { fields[fieldNum] = d }
263
279
  default:
264
280
  skipField(wireType: wireType)
@@ -280,21 +296,21 @@ private enum EvenHubProto {
280
296
  content: String? = nil
281
297
  ) -> Data {
282
298
  var w = ProtobufWriter()
283
- w.writeInt32Field(1, x) // XPosition
284
- w.writeInt32Field(2, y) // YPosition
285
- w.writeInt32Field(3, width) // Width
286
- w.writeInt32Field(4, height) // Height
287
- w.writeInt32Field(5, borderWidth) // BorderWidth
288
- w.writeInt32Field(6, borderColor) // BorderColor
289
- w.writeInt32Field(7, borderRadius) // BorderRdaius (sic - typo in proto)
290
- w.writeInt32Field(8, paddingLength) // PaddingLength
291
- 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
292
308
  if let name = containerName {
293
- w.writeStringField(10, name) // ContainerName
309
+ w.writeStringField(10, name) // ContainerName
294
310
  }
295
- w.writeInt32Field(11, isEventCapture ? 1 : 0) // IsEventCapture
311
+ w.writeInt32Field(11, isEventCapture ? 1 : 0) // IsEventCapture
296
312
  if let content = content {
297
- w.writeStringField(12, content) // Content
313
+ w.writeStringField(12, content) // Content
298
314
  }
299
315
  return w.data
300
316
  }
@@ -305,13 +321,13 @@ private enum EvenHubProto {
305
321
  containerID: Int32, containerName: String? = nil
306
322
  ) -> Data {
307
323
  var w = ProtobufWriter()
308
- w.writeInt32Field(1, x) // XPosition
309
- w.writeInt32Field(2, y) // YPosition
310
- w.writeInt32Field(3, width) // Width
311
- w.writeInt32Field(4, height) // Height
312
- 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
313
329
  if let name = containerName {
314
- w.writeStringField(6, name) // ContainerName
330
+ w.writeStringField(6, name) // ContainerName
315
331
  }
316
332
  return w.data
317
333
  }
@@ -323,16 +339,16 @@ private enum EvenHubProto {
323
339
  mapFragmentIndex: Int32, mapFragmentPacketSize: Int32, mapRawData: Data
324
340
  ) -> Data {
325
341
  var w = ProtobufWriter()
326
- w.writeInt32Field(1, containerID) // ContainerID
342
+ w.writeInt32Field(1, containerID) // ContainerID
327
343
  if let name = containerName {
328
- w.writeStringField(2, name) // ContainerName
329
- }
330
- w.writeInt32Field(3, mapSessionId) // MapSessionId
331
- w.writeInt32Field(4, mapTotalSize) // MapTotalSize
332
- w.writeInt32Field(5, compressMode) // CompressMode
333
- w.writeInt32Field(6, mapFragmentIndex) // MapFragmentIndex
334
- w.writeInt32Field(7, mapFragmentPacketSize) // MapFragmentPacketSize
335
- 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
336
352
  return w.data
337
353
  }
338
354
 
@@ -343,13 +359,13 @@ private enum EvenHubProto {
343
359
  imageContainers: [Data] = []
344
360
  ) -> Data {
345
361
  var w = ProtobufWriter()
346
- w.writeInt32Field(1, containerTotalNum) // ContainerTotalNum
362
+ w.writeInt32Field(1, containerTotalNum) // ContainerTotalNum
347
363
  // field 2 = repeated ListContainerProperty ListObject (not used here)
348
364
  for tc in textContainers {
349
- w.writeMessageField(3, tc) // field 3 = repeated TextObject
365
+ w.writeMessageField(3, tc) // field 3 = repeated TextObject
350
366
  }
351
367
  for ic in imageContainers {
352
- w.writeMessageField(4, ic) // field 4 = repeated ImageObject
368
+ w.writeMessageField(4, ic) // field 4 = repeated ImageObject
353
369
  }
354
370
  return w.data
355
371
  }
@@ -360,17 +376,17 @@ private enum EvenHubProto {
360
376
  contentLength: Int32, content: String
361
377
  ) -> Data {
362
378
  var w = ProtobufWriter()
363
- w.writeInt32Field(1, containerID) // ContainerID
364
- w.writeInt32Field(3, contentOffset) // ContentOffset
365
- w.writeInt32Field(4, contentLength) // ContentLength
366
- 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
367
383
  return w.data
368
384
  }
369
385
 
370
386
  /// Build a ShutDownContaniner message (sic - typo in proto)
371
387
  static func shutdownContainer(exitMode: Int32 = 0) -> Data {
372
388
  var w = ProtobufWriter()
373
- w.writeInt32Field(1, exitMode) // exitMode
389
+ w.writeInt32Field(1, exitMode) // exitMode
374
390
  return w.data
375
391
  }
376
392
 
@@ -378,7 +394,7 @@ private enum EvenHubProto {
378
394
  static func heartbeatPacket(cnt: Int32 = 0) -> Data {
379
395
  var w = ProtobufWriter()
380
396
  if cnt != 0 {
381
- w.writeInt32Field(1, cnt) // Cnt
397
+ w.writeInt32Field(1, cnt) // Cnt
382
398
  }
383
399
  return w.data
384
400
  }
@@ -386,7 +402,7 @@ private enum EvenHubProto {
386
402
  /// Build an AudioCtrCmd message
387
403
  static func audioCtrCmd(enable: Bool) -> Data {
388
404
  var w = ProtobufWriter()
389
- w.writeInt32Field(1, enable ? 1 : 0) // AudoFuncEn
405
+ w.writeInt32Field(1, enable ? 1 : 0) // AudoFuncEn
390
406
  return w.data
391
407
  }
392
408
 
@@ -397,11 +413,11 @@ private enum EvenHubProto {
397
413
  appId: Int32? = nil
398
414
  ) -> Data {
399
415
  var w = ProtobufWriter()
400
- w.writeInt32Field(1, cmd.rawValue) // Cmd (field 1, enum)
401
- w.writeInt32Field(2, magicRandom) // MagicRandom (field 2)
402
- 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
403
419
  if let appId = appId {
404
- w.writeInt32Field(5, appId) // Associate page with a menu item appId
420
+ w.writeInt32Field(5, appId) // Associate page with a menu item appId
405
421
  }
406
422
  return w.data
407
423
  }
@@ -486,6 +502,48 @@ private enum EvenHubProto {
486
502
  cmd: .audioControl, subFieldNumber: 18, subMessage: audioMsg, magicRandom: magicRandom
487
503
  )
488
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
+ }
489
547
  }
490
548
 
491
549
  // MARK: - DevSettings Auth Protobuf Builders
@@ -498,17 +556,17 @@ private enum DevSettingsProto {
498
556
  // field 2 = magicRandom (int32)
499
557
  // field 3 = authMgr (AuthMgr message)
500
558
  var w = ProtobufWriter()
501
- w.writeInt32Field(1, DevCfgCommandId.authentication.rawValue) // commandId
502
- w.writeInt32Field(2, magicRandom) // magicRandom
559
+ w.writeInt32Field(1, DevCfgCommandId.authentication.rawValue) // commandId
560
+ w.writeInt32Field(2, magicRandom) // magicRandom
503
561
 
504
562
  // AuthMgr sub-message:
505
563
  // field 1 = secAuth (bool)
506
564
  // field 2 = phoneType (enum eDevice: PHONE_IOS=3, PHONE_ANDROID=4)
507
565
  var authW = ProtobufWriter()
508
- authW.writeBoolField(1, true) // secAuth
509
- 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)
510
568
 
511
- w.writeMessageField(3, authW.data) // authMgr
569
+ w.writeMessageField(3, authW.data) // authMgr
512
570
  return w.data
513
571
  }
514
572
 
@@ -520,8 +578,8 @@ private enum DevSettingsProto {
520
578
 
521
579
  // PipeRoleChange: field 1 = asCmdRole (enum GlassesLR.RIGHT=1)
522
580
  var roleW = ProtobufWriter()
523
- roleW.writeInt32Field(1, 1) // RIGHT
524
- 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)
525
583
  return w.data
526
584
  }
527
585
 
@@ -538,7 +596,7 @@ private enum DevSettingsProto {
538
596
  let nowSec = Int64(Date().timeIntervalSince1970)
539
597
  let tzSec = Int64(TimeZone.current.secondsFromGMT())
540
598
  tsW.writeInt32Field(1, Int32(truncatingIfNeeded: nowSec + tzSec))
541
- w.writeMessageField(128, tsW.data) // timeSync (field 128 in DevCfgDataPackage)
599
+ w.writeMessageField(128, tsW.data) // timeSync (field 128 in DevCfgDataPackage)
542
600
  return w.data
543
601
  }
544
602
 
@@ -578,8 +636,8 @@ private enum DevSettingsProto {
578
636
 
579
637
  // BaseConnHeartBeat: empty message
580
638
  var hbW = ProtobufWriter()
581
- _ = hbW // empty
582
- w.writeMessageField(13, hbW.data) // baseHeartBeat (field 13)
639
+ _ = hbW // empty
640
+ w.writeMessageField(13, hbW.data) // baseHeartBeat (field 13)
583
641
  return w.data
584
642
  }
585
643
 
@@ -590,18 +648,18 @@ private enum DevSettingsProto {
590
648
  magicRandom: Int32, connect: Bool, ringMac: Data, ringName: String = ""
591
649
  ) -> Data {
592
650
  var w = ProtobufWriter()
593
- w.writeInt32Field(1, DevCfgCommandId.ringConnectInfo.rawValue) // commandId = RING_CONNECT_INFO (6)
651
+ w.writeInt32Field(1, DevCfgCommandId.ringConnectInfo.rawValue) // commandId = RING_CONNECT_INFO (6)
594
652
  w.writeInt32Field(2, magicRandom)
595
653
 
596
654
  // RingInfo sub-message (field 5 in DevCfgDataPackage)
597
655
  var ringW = ProtobufWriter()
598
- ringW.writeBoolField(1, connect) // connectRing
599
- ringW.writeBytesField(2, ringMac) // ringMac (6 bytes)
656
+ ringW.writeBoolField(1, connect) // connectRing
657
+ ringW.writeBytesField(2, ringMac) // ringMac (6 bytes)
600
658
  if !ringName.isEmpty {
601
- ringW.writeBytesField(3, Data(ringName.utf8)) // ringName
659
+ ringW.writeBytesField(3, Data(ringName.utf8)) // ringName
602
660
  }
603
661
 
604
- w.writeMessageField(5, ringW.data) // ringInfo (field 5)
662
+ w.writeMessageField(5, ringW.data) // ringInfo (field 5)
605
663
  return w.data
606
664
  }
607
665
  }
@@ -613,18 +671,18 @@ private enum G2SettingProto {
613
671
  static func setBrightness(magicRandom: Int32, level: Int32, autoAdjust: Bool) -> Data {
614
672
  // DeviceReceive_Brightness
615
673
  var brightnessW = ProtobufWriter()
616
- brightnessW.writeInt32Field(1, autoAdjust ? 1 : 0) // autoAdjust
617
- brightnessW.writeInt32Field(2, level) // brightnessLevel
674
+ brightnessW.writeInt32Field(1, autoAdjust ? 1 : 0) // autoAdjust
675
+ brightnessW.writeInt32Field(2, level) // brightnessLevel
618
676
 
619
677
  // DeviceReceiveInfoFromAPP
620
678
  var infoW = ProtobufWriter()
621
- infoW.writeMessageField(1, brightnessW.data) // deviceReceiveBrightness (field 1)
679
+ infoW.writeMessageField(1, brightnessW.data) // deviceReceiveBrightness (field 1)
622
680
 
623
681
  // G2SettingPackage
624
682
  var w = ProtobufWriter()
625
- w.writeInt32Field(1, G2SettingCommandId.deviceReceiveInfo.rawValue) // commandId
683
+ w.writeInt32Field(1, G2SettingCommandId.deviceReceiveInfo.rawValue) // commandId
626
684
  w.writeInt32Field(2, magicRandom)
627
- w.writeMessageField(3, infoW.data) // deviceReceiveInfoFromApp (field 3)
685
+ w.writeMessageField(3, infoW.data) // deviceReceiveInfoFromApp (field 3)
628
686
  return w.data
629
687
  }
630
688
 
@@ -633,13 +691,13 @@ private enum G2SettingProto {
633
691
  // DeviceReceiveRequestFromAPP - empty message triggers glasses to respond with all fields
634
692
  var reqW = ProtobufWriter()
635
693
  // Request brightness info type
636
- reqW.writeInt32Field(1, 1) // settingInfoType = APP_REQUIRE_BASIC_SETTING
694
+ reqW.writeInt32Field(1, 1) // settingInfoType = APP_REQUIRE_BASIC_SETTING
637
695
 
638
696
  // G2SettingPackage
639
697
  var w = ProtobufWriter()
640
- w.writeInt32Field(1, G2SettingCommandId.deviceReceiveRequest.rawValue) // commandId
698
+ w.writeInt32Field(1, G2SettingCommandId.deviceReceiveRequest.rawValue) // commandId
641
699
  w.writeInt32Field(2, magicRandom)
642
- w.writeMessageField(4, reqW.data) // deviceReceiveRequestFromApp (field 4)
700
+ w.writeMessageField(4, reqW.data) // deviceReceiveRequestFromApp (field 4)
643
701
  return w.data
644
702
  }
645
703
 
@@ -647,17 +705,17 @@ private enum G2SettingProto {
647
705
  static func setHeadUpSwitch(magicRandom: Int32, enabled: Bool) -> Data {
648
706
  // DeviceReceive_Head_UP_Setting
649
707
  var headUpW = ProtobufWriter()
650
- headUpW.writeInt32Field(1, enabled ? 1 : 0) // headUpSwitch
708
+ headUpW.writeInt32Field(1, enabled ? 1 : 0) // headUpSwitch
651
709
 
652
710
  // DeviceReceiveInfoFromAPP
653
711
  var infoW = ProtobufWriter()
654
- infoW.writeMessageField(4, headUpW.data) // deviceReceiveHeadUpSetting (field 4)
712
+ infoW.writeMessageField(4, headUpW.data) // deviceReceiveHeadUpSetting (field 4)
655
713
 
656
714
  // G2SettingPackage
657
715
  var w = ProtobufWriter()
658
716
  w.writeInt32Field(1, G2SettingCommandId.deviceReceiveInfo.rawValue)
659
717
  w.writeInt32Field(2, magicRandom)
660
- w.writeMessageField(3, infoW.data) // deviceReceiveInfoFromApp (field 3)
718
+ w.writeMessageField(3, infoW.data) // deviceReceiveInfoFromApp (field 3)
661
719
  return w.data
662
720
  }
663
721
 
@@ -665,11 +723,11 @@ private enum G2SettingProto {
665
723
  static func setHeadUpAngle(magicRandom: Int32, angle: Int32) -> Data {
666
724
  // DeviceReceive_Head_UP_Setting
667
725
  var headUpW = ProtobufWriter()
668
- headUpW.writeInt32Field(2, angle) // headUpAngle (field 2)
726
+ headUpW.writeInt32Field(2, angle) // headUpAngle (field 2)
669
727
 
670
728
  // DeviceReceiveInfoFromAPP
671
729
  var infoW = ProtobufWriter()
672
- infoW.writeMessageField(4, headUpW.data) // deviceReceiveHeadUpSetting (field 4)
730
+ infoW.writeMessageField(4, headUpW.data) // deviceReceiveHeadUpSetting (field 4)
673
731
 
674
732
  // G2SettingPackage
675
733
  var w = ProtobufWriter()
@@ -683,11 +741,11 @@ private enum G2SettingProto {
683
741
  static func setScreenHeight(magicRandom: Int32, level: Int32) -> Data {
684
742
  // DeviceReceive_Y_Coordinate
685
743
  var yW = ProtobufWriter()
686
- yW.writeInt32Field(1, level) // yCoordinateLevel
744
+ yW.writeInt32Field(1, level) // yCoordinateLevel
687
745
 
688
746
  // DeviceReceiveInfoFromAPP
689
747
  var infoW = ProtobufWriter()
690
- infoW.writeMessageField(2, yW.data) // deviceReceiveYCoordinate (field 2)
748
+ infoW.writeMessageField(2, yW.data) // deviceReceiveYCoordinate (field 2)
691
749
 
692
750
  // G2SettingPackage
693
751
  var w = ProtobufWriter()
@@ -701,11 +759,11 @@ private enum G2SettingProto {
701
759
  static func setScreenDepth(magicRandom: Int32, level: Int32) -> Data {
702
760
  // DeviceReceive_X_Coordinate
703
761
  var xW = ProtobufWriter()
704
- xW.writeInt32Field(1, level) // xCoordinateLevel
762
+ xW.writeInt32Field(1, level) // xCoordinateLevel
705
763
 
706
764
  // DeviceReceiveInfoFromAPP
707
765
  var infoW = ProtobufWriter()
708
- infoW.writeMessageField(3, xW.data) // deviceReceiveXCoordinate (field 3)
766
+ infoW.writeMessageField(3, xW.data) // deviceReceiveXCoordinate (field 3)
709
767
 
710
768
  // G2SettingPackage
711
769
  var w = ProtobufWriter()
@@ -723,13 +781,13 @@ private enum OnboardingProto {
723
781
  static func skipOnboarding(magicRandom: Int32) -> Data {
724
782
  // OnboardingConfig: processId = FINISH (4)
725
783
  var configW = ProtobufWriter()
726
- configW.writeInt32Field(1, 4) // processId = FINISH
784
+ configW.writeInt32Field(1, 4) // processId = FINISH
727
785
 
728
786
  // OnboardingDataPackage
729
787
  var w = ProtobufWriter()
730
- w.writeInt32Field(1, 1) // commandId = CONFIG
788
+ w.writeInt32Field(1, 1) // commandId = CONFIG
731
789
  w.writeInt32Field(2, magicRandom)
732
- w.writeMessageField(3, configW.data) // config (field 3)
790
+ w.writeMessageField(3, configW.data) // config (field 3)
733
791
  return w.data
734
792
  }
735
793
  }
@@ -737,19 +795,27 @@ private enum OnboardingProto {
737
795
  // MARK: - EvenAI Protobuf Builders (even_ai.proto, service ID 7)
738
796
 
739
797
  private enum EvenAIProto {
740
- /// EvenAIDataPackage with CONFIG command to toggle Hey Even wakeword
741
- /// 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})
742
806
  static func setHeyEven(magicRandom: Int32, enabled: Bool) -> Data {
743
807
  // EvenAIConfig
744
808
  var configW = ProtobufWriter()
745
- configW.writeInt32Field(1, enabled ? 1 : 0) // voiceSwitch
746
- 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)
747
813
 
748
814
  // EvenAIDataPackage
749
815
  var w = ProtobufWriter()
750
- w.writeInt32Field(1, 10) // commandId = CONFIG
816
+ w.writeInt32Field(1, 10) // commandId = CONFIG
751
817
  w.writeInt32Field(2, magicRandom)
752
- w.writeMessageField(13, configW.data) // config (field 13)
818
+ w.writeMessageField(13, configW.data) // config (field 13)
753
819
  return w.data
754
820
  }
755
821
 
@@ -759,13 +825,13 @@ private enum EvenAIProto {
759
825
  /// into the glasses' AI session so the following SKILL packet has context.
760
826
  static func aiAsk(magicRandom: Int32, text: String, streamEnable: Int32 = 0) -> Data {
761
827
  var askW = ProtobufWriter()
762
- askW.writeInt32Field(2, streamEnable) // streamEnable
763
- askW.writeBytesField(4, Data(text.utf8)) // text
828
+ askW.writeInt32Field(2, streamEnable) // streamEnable
829
+ askW.writeBytesField(4, Data(text.utf8)) // text
764
830
 
765
831
  var w = ProtobufWriter()
766
- w.writeInt32Field(1, 3) // commandId = ASK
832
+ w.writeInt32Field(1, 3) // commandId = ASK
767
833
  w.writeInt32Field(2, magicRandom)
768
- w.writeMessageField(5, askW.data) // askInfo (field 5)
834
+ w.writeMessageField(5, askW.data) // askInfo (field 5)
769
835
  return w.data
770
836
  }
771
837
 
@@ -775,12 +841,12 @@ private enum EvenAIProto {
775
841
  /// status: 1 WAKE_UP, 2 ENTER, 3 EXIT
776
842
  static func aiCtrl(magicRandom: Int32, status: Int32) -> Data {
777
843
  var ctrlW = ProtobufWriter()
778
- ctrlW.writeInt32Field(1, status) // status
844
+ ctrlW.writeInt32Field(1, status) // status
779
845
 
780
846
  var w = ProtobufWriter()
781
- w.writeInt32Field(1, 1) // commandId = CTRL
847
+ w.writeInt32Field(1, 1) // commandId = CTRL
782
848
  w.writeInt32Field(2, magicRandom)
783
- w.writeMessageField(3, ctrlW.data) // ctrl (field 3)
849
+ w.writeMessageField(3, ctrlW.data) // ctrl (field 3)
784
850
  return w.data
785
851
  }
786
852
 
@@ -795,17 +861,17 @@ private enum EvenAIProto {
795
861
  ) -> Data {
796
862
  // EvenAISkillInfo
797
863
  var skillW = ProtobufWriter()
798
- skillW.writeInt32Field(1, streamEnable) // streamEnable
799
- skillW.writeInt32Field(2, skillId) // skillId
800
- skillW.writeInt32Field(3, skillParam) // skillParam — for NOTIFICATION skill this is a NotificationType enum
801
- skillW.writeBytesField(4, Data(text.utf8)) // text (utterance / payload)
802
- skillW.writeInt32Field(6, fTextEnd) // fTextEnd — 1 signals "this is the final/complete packet"
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"
803
869
 
804
870
  // EvenAIDataPackage
805
871
  var w = ProtobufWriter()
806
- w.writeInt32Field(1, 6) // commandId = SKILL
872
+ w.writeInt32Field(1, 6) // commandId = SKILL
807
873
  w.writeInt32Field(2, magicRandom)
808
- w.writeMessageField(8, skillW.data) // skillInfo (field 8)
874
+ w.writeMessageField(8, skillW.data) // skillInfo (field 8)
809
875
  return w.data
810
876
  }
811
877
  }
@@ -820,13 +886,13 @@ private enum NotificationProto {
820
886
  /// (Returned errorCode=8 NOT_SUPPORT in testing — Service 4 doesn't accept this outbound.)
821
887
  static func iosNotification(magicRandom: Int32, appID: String, displayName: String) -> Data {
822
888
  var iosW = ProtobufWriter()
823
- iosW.writeBytesField(1, Data(appID.utf8)) // appID
824
- iosW.writeBytesField(2, Data(displayName.utf8)) // displayName
889
+ iosW.writeBytesField(1, Data(appID.utf8)) // appID
890
+ iosW.writeBytesField(2, Data(displayName.utf8)) // displayName
825
891
 
826
892
  var w = ProtobufWriter()
827
- w.writeInt32Field(1, 2) // commandId = NOTIFICATION_IOS
893
+ w.writeInt32Field(1, 2) // commandId = NOTIFICATION_IOS
828
894
  w.writeInt32Field(2, magicRandom)
829
- w.writeMessageField(4, iosW.data) // IOS (field 4)
895
+ w.writeMessageField(4, iosW.data) // IOS (field 4)
830
896
  return w.data
831
897
  }
832
898
 
@@ -843,15 +909,15 @@ private enum NotificationProto {
843
909
  avoidDisturbEnable: Int32 = 0
844
910
  ) -> Data {
845
911
  var ctrlW = ProtobufWriter()
846
- ctrlW.writeInt32Field(1, notifEnable) // notifEnable
847
- ctrlW.writeInt32Field(2, autoDispEnable) // autoDispEnable
848
- ctrlW.writeInt32Field(3, dispTime) // dispTime (seconds)
849
- ctrlW.writeInt32Field(5, avoidDisturbEnable) // avoidDisturbEnable
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
850
916
 
851
917
  var w = ProtobufWriter()
852
- w.writeInt32Field(1, 1) // commandId = NOTIFICATION_CTRL
918
+ w.writeInt32Field(1, 1) // commandId = NOTIFICATION_CTRL
853
919
  w.writeInt32Field(2, magicRandom)
854
- w.writeMessageField(3, ctrlW.data) // ctrl (field 3)
920
+ w.writeMessageField(3, ctrlW.data) // ctrl (field 3)
855
921
  return w.data
856
922
  }
857
923
  }
@@ -869,7 +935,7 @@ private enum MenuProto {
869
935
  /// G2 firmware requires minimum 5, maximum 10 menu items
870
936
  static let MIN_MENU_SIZE = 5
871
937
  static let MAX_MENU_SIZE = 10
872
- 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
873
939
  /// Placeholder appIds for padding slots (in valid Even range, unique per slot)
874
940
  static let PLACEHOLDER_APP_IDS: [Int32] = [10535, 10536, 10537, 10538, 10539]
875
941
 
@@ -896,7 +962,7 @@ private enum MenuProto {
896
962
 
897
963
  // Wire items carry either a built-in (itemType=0, no name) or third-party (itemType=1, with name)
898
964
  struct WireItem {
899
- let displayName: String? // nil for built-ins
965
+ let displayName: String? // nil for built-ins
900
966
  let appId: Int32
901
967
  let isBuiltIn: Bool
902
968
  }
@@ -913,8 +979,8 @@ private enum MenuProto {
913
979
 
914
980
  let truncated =
915
981
  item.name.count > MAX_NAME_LENGTH
916
- ? String(item.name.prefix(MAX_NAME_LENGTH))
917
- : item.name
982
+ ? String(item.name.prefix(MAX_NAME_LENGTH))
983
+ : item.name
918
984
  let prefix = item.running ? "● " : ""
919
985
  wireItems.append(
920
986
  WireItem(displayName: prefix + truncated, appId: appId, isBuiltIn: false)
@@ -923,7 +989,7 @@ private enum MenuProto {
923
989
 
924
990
  // Pad to MIN_MENU_SIZE with placeholder third-party items
925
991
  while wireItems.count < MIN_MENU_SIZE {
926
- 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
927
993
  wireItems.append(
928
994
  WireItem(
929
995
  displayName: " ---",
@@ -935,27 +1001,27 @@ private enum MenuProto {
935
1001
 
936
1002
  // MenuInfoSend
937
1003
  var menuW = ProtobufWriter()
938
- menuW.writeInt32Field(1, Int32(wireItems.count)) // itemTotalNum
1004
+ menuW.writeInt32Field(1, Int32(wireItems.count)) // itemTotalNum
939
1005
 
940
1006
  for item in wireItems {
941
1007
  var itemW = ProtobufWriter()
942
1008
  if item.isBuiltIn {
943
- itemW.writeInt32Field(1, 0) // itemType = 0 (built-in)
944
- itemW.writeInt32Field(4, item.appId) // itemAppId = SID
1009
+ itemW.writeInt32Field(1, 0) // itemType = 0 (built-in)
1010
+ itemW.writeInt32Field(4, item.appId) // itemAppId = SID
945
1011
  } else {
946
- itemW.writeInt32Field(1, 1) // itemType = 1 (third-party)
947
- itemW.writeInt32Field(2, 1) // iconNum = 1
948
- itemW.writeStringField(3, item.displayName ?? "") // itemName
949
- 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
950
1016
  }
951
- menuW.writeMessageField(2, itemW.data) // repeated item (field 2)
1017
+ menuW.writeMessageField(2, itemW.data) // repeated item (field 2)
952
1018
  }
953
1019
 
954
1020
  // meun_main_msg_ctx
955
1021
  var w = ProtobufWriter()
956
- w.writeInt32Field(1, 0) // Cmd = APP_SEND_MENU_INFO (0)
957
- w.writeInt32Field(2, magicRandom) // MagicRandom
958
- 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)
959
1025
  return (w.data, appIdMap)
960
1026
  }
961
1027
  }
@@ -968,7 +1034,7 @@ private enum DashboardProto {
968
1034
  /// eDashboardCommandId values from dashboard.proto
969
1035
  enum CommandId: Int32 {
970
1036
  case dashboardRespond = 1
971
- case dashboardReceive = 2 // phone → glasses widget/config push
1037
+ case dashboardReceive = 2 // phone → glasses widget/config push
972
1038
  case appRespond = 3
973
1039
  case appReceive = 4
974
1040
  }
@@ -1113,7 +1179,7 @@ private struct EvenBLETransport {
1113
1179
  var offset = 0
1114
1180
  while offset < payload.count {
1115
1181
  let end = min(offset + maxPayload, payload.count)
1116
- chunks.append(payload[offset ..< end])
1182
+ chunks.append(payload[offset..<end])
1117
1183
  offset = end
1118
1184
  }
1119
1185
  if chunks.isEmpty {
@@ -1141,20 +1207,20 @@ private struct EvenBLETransport {
1141
1207
  let payloadLen = UInt8(chunk.count + (isLast ? 2 : 0))
1142
1208
 
1143
1209
  var packet = Data()
1144
- packet.append(G2BLE.HEADER_BYTE) // [0] 0xAA
1145
- packet.append((G2BLE.DEST_GLASSES << 4) | G2BLE.SOURCE_PHONE) // [1] src+dst
1146
- packet.append(syncId) // [2] syncId
1147
- packet.append(payloadLen) // [3] payloadLen
1148
- packet.append(totalPackets) // [4] packetTotalNum
1149
- packet.append(serialNum) // [5] packetSerialNum
1150
- packet.append(serviceId) // [6] serviceId
1151
- 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
1152
1218
 
1153
1219
  packet.append(chunk)
1154
1220
 
1155
1221
  if isLast {
1156
- packet.append(UInt8(crc & 0xFF)) // CRC low
1157
- 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
1158
1224
  }
1159
1225
 
1160
1226
  packets.append(packet)
@@ -1194,9 +1260,10 @@ private class G2SendManager {
1194
1260
  // MARK: - G2 Receive Manager (multi-part reassembly)
1195
1261
 
1196
1262
  private class G2ReceiveManager {
1197
- private var partials: [String: (Data, UInt8)] = [:] // key -> (accumulated payload, lastSerialNum)
1263
+ private var partials: [String: (Data, UInt8)] = [:] // key -> (accumulated payload, lastSerialNum)
1198
1264
 
1199
- func handlePacket(_ rawData: Data, sourceKey: String = "") -> (serviceId: UInt8, payload: Data)? {
1265
+ func handlePacket(_ rawData: Data, sourceKey: String = "") -> (serviceId: UInt8, payload: Data)?
1266
+ {
1200
1267
  guard rawData.count >= 8 else { return nil }
1201
1268
  guard rawData[0] == G2BLE.HEADER_BYTE else { return nil }
1202
1269
 
@@ -1215,7 +1282,7 @@ private class G2ReceiveManager {
1215
1282
  let isLast = (serialNum == totalPackets)
1216
1283
  let hasCrc = isLast
1217
1284
  let payloadEnd = 8 + payloadLen - (hasCrc ? 2 : 0)
1218
- let payload = rawData[8 ..< payloadEnd]
1285
+ let payload = rawData[8..<payloadEnd]
1219
1286
 
1220
1287
  let syncId = rawData[2]
1221
1288
  // Key partials by source peripheral too — left and right glasses have independent syncId counters
@@ -1262,7 +1329,7 @@ actor G2ReconnectionManager {
1262
1329
  private var task: Task<Void, Never>?
1263
1330
  private let intervalSeconds: TimeInterval
1264
1331
  private var attempts = 0
1265
- private let maxAttempts: Int // -1 for unlimited
1332
+ private let maxAttempts: Int // -1 for unlimited
1266
1333
 
1267
1334
  init(intervalSeconds: TimeInterval = 30, maxAttempts: Int = -1) {
1268
1335
  self.intervalSeconds = intervalSeconds
@@ -1348,12 +1415,18 @@ class G2: NSObject, SGCManager {
1348
1415
  /// Maps serial number -> peripheral UUID string. Persisted across forget() so previously
1349
1416
  /// paired devices can reconnect quickly without a fresh scan.
1350
1417
  private var leftGlassUUIDMap: [String: String] {
1351
- get { UserDefaults.standard.dictionary(forKey: "g2_leftGlassUUIDMap") as? [String: String] ?? [:] }
1418
+ get {
1419
+ UserDefaults.standard.dictionary(forKey: "g2_leftGlassUUIDMap") as? [String: String]
1420
+ ?? [:]
1421
+ }
1352
1422
  set { UserDefaults.standard.set(newValue, forKey: "g2_leftGlassUUIDMap") }
1353
1423
  }
1354
1424
 
1355
1425
  private var rightGlassUUIDMap: [String: String] {
1356
- get { UserDefaults.standard.dictionary(forKey: "g2_rightGlassUUIDMap") as? [String: String] ?? [:] }
1426
+ get {
1427
+ UserDefaults.standard.dictionary(forKey: "g2_rightGlassUUIDMap") as? [String: String]
1428
+ ?? [:]
1429
+ }
1357
1430
  set { UserDefaults.standard.set(newValue, forKey: "g2_rightGlassUUIDMap") }
1358
1431
  }
1359
1432
 
@@ -1384,9 +1457,8 @@ class G2: NSObject, SGCManager {
1384
1457
  private let sendManager = G2SendManager()
1385
1458
  private let receiveManager = G2ReceiveManager()
1386
1459
  private var foregroundObserver: NSObjectProtocol?
1387
- private var startupPageCreated: Bool = false // createStartUpPageContainer can only be called once
1460
+ private var startupPageCreated: Bool = false // createStartUpPageContainer can only be called once
1388
1461
  private var pageCreated: Bool = false
1389
- private var pageHasTextContainer: Bool = false // tracks if current page has a text container
1390
1462
  private var currentTextContent: String = ""
1391
1463
  private var currentBitmapBase64: String = ""
1392
1464
  private var textContainerID: Int32 = 1
@@ -1409,6 +1481,7 @@ class G2: NSObject, SGCManager {
1409
1481
  /// Set to the first menu item's appId so glasses know our page belongs to the menu
1410
1482
  private var activeMenuAppId: Int32?
1411
1483
  private var lastClickTimestamp: Int64?
1484
+ private var lastEvenHubResponseTimestamp: Int64?
1412
1485
  private var lastMenuSelectTimestamp: Int64?
1413
1486
  private var lastGestureCtrlTimestamp: Int64?
1414
1487
 
@@ -1419,19 +1492,56 @@ class G2: NSObject, SGCManager {
1419
1492
  let y: Int32
1420
1493
  let width: Int32
1421
1494
  let height: Int32
1422
- var name: String { "img-\(id)" }
1495
+ var name: String {
1496
+ "img-\(id)"
1497
+ }
1498
+ var bmpData: Data
1499
+
1423
1500
  func matches(x: Int32, y: Int32, width: Int32, height: Int32) -> Bool {
1424
1501
  self.x == x && self.y == y && self.width == width && self.height == height
1425
1502
  }
1426
1503
  }
1427
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
+
1428
1530
  /// Live list of image containers on the page, ordered oldest→newest (for LRU eviction).
1429
1531
  /// The page may hold at most 4 image containers (IDs from the pool below).
1430
1532
  private var imageContainers: [ImgContainer] = []
1533
+ private var textContainers: [TextContainer] = []
1431
1534
  /// Fixed pool of container IDs the page protocol expects.
1432
1535
  private let imageContainerIDPool: [Int32] = [10, 11, 12, 13]
1536
+ private let textContainerIDPool: [Int32] = [1, 2, 3, 4, 5, 6]
1433
1537
  /// Default container seeded into every fresh page: 100x100 in the top-left.
1434
- private static let defaultImgContainer = (x: Int32(288), y: Int32(144), width: Int32(200), height: Int32(100))
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
+ )
1435
1545
 
1436
1546
  @Published var aiListening: Bool = false
1437
1547
 
@@ -1455,6 +1565,7 @@ class G2: NSObject, SGCManager {
1455
1565
  // MARK: - BLE Sending
1456
1566
 
1457
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])")
1458
1569
  for packet in packets {
1459
1570
  if right, let char = rightWriteChar, let peripheral = rightPeripheral {
1460
1571
  peripheral.writeValue(packet, for: char, type: .withoutResponse)
@@ -1482,6 +1593,15 @@ class G2: NSObject, SGCManager {
1482
1593
  sendToGlasses(packets, left: left, right: right)
1483
1594
  }
1484
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
+
1485
1605
  private func sendG2SettingCommand(_ payload: Data) {
1486
1606
  let packets = sendManager.buildPackets(
1487
1607
  serviceId: ServiceID.g2Setting.rawValue,
@@ -1569,7 +1689,7 @@ class G2: NSObject, SGCManager {
1569
1689
  sendDevSettingsCommand(authR, left: false, right: true)
1570
1690
  }
1571
1691
 
1572
- private func runAuthSequence() {
1692
+ private func runAuthSequence() async {
1573
1693
  Bridge.log("G2: Running auth sequence")
1574
1694
 
1575
1695
  // Auth to left side
@@ -1579,206 +1699,150 @@ class G2: NSObject, SGCManager {
1579
1699
  }
1580
1700
 
1581
1701
  // Small delay then auth right + pipe role change + time sync
1582
- DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { [weak self] in
1583
- guard let self = self else { return }
1702
+ try? await Task.sleep(nanoseconds: 200_000_000)
1584
1703
 
1585
- let authR = DevSettingsProto.authCmd(magicRandom: self.sendManager.nextMagicRandom())
1586
- self.sendDevSettingsCommand(authR, left: false, right: true)
1704
+ let authR = DevSettingsProto.authCmd(magicRandom: self.sendManager.nextMagicRandom())
1705
+ self.sendDevSettingsCommand(authR, left: false, right: true)
1587
1706
 
1588
- DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { [weak self] in
1589
- guard let self = self else { return }
1707
+ try? await Task.sleep(nanoseconds: 200_000_000)
1590
1708
 
1591
- let roleChange = DevSettingsProto.pipeRoleChange(
1592
- magicRandom: self.sendManager.nextMagicRandom()
1593
- )
1594
- self.sendDevSettingsCommand(roleChange, left: false, right: true)
1709
+ let roleChange = DevSettingsProto.pipeRoleChange(
1710
+ magicRandom: self.sendManager.nextMagicRandom()
1711
+ )
1712
+ self.sendDevSettingsCommand(roleChange, left: false, right: true)
1595
1713
 
1596
- DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { [weak self] in
1597
- guard let self = self else { return }
1714
+ try? await Task.sleep(nanoseconds: 200_000_000)
1598
1715
 
1599
- let timeSync = DevSettingsProto.timeSync(
1600
- magicRandom: self.sendManager.nextMagicRandom()
1601
- )
1602
- self.sendDevSettingsCommand(timeSync)
1716
+ let timeSync = DevSettingsProto.timeSync(
1717
+ magicRandom: self.sendManager.nextMagicRandom()
1718
+ )
1719
+ self.sendDevSettingsCommand(timeSync)
1603
1720
 
1604
- // Skip onboarding on connect
1605
- DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { [weak self] in
1606
- guard let self = self else { return }
1607
- let onboarding = OnboardingProto.skipOnboarding(
1608
- magicRandom: self.sendManager.nextMagicRandom()
1609
- )
1610
- self.sendOnboardingCommand(onboarding)
1611
- Bridge.log("G2: Sent onboarding skip (FINISH)")
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
+ )
1612
1745
 
1613
- // Disable "Hey Even" wakeword on connect
1614
- let heyEvenOff = EvenAIProto.setHeyEven(
1615
- magicRandom: self.sendManager.nextMagicRandom(),
1616
- enabled: false
1617
- )
1618
- self.sendEvenAICommand(heyEvenOff)
1619
- Bridge.log("G2: Disabled Hey Even wakeword")
1620
-
1621
- // Replicate Even app's full init sequence for menu selection support:
1622
-
1623
- // 0. Universe settings (g2_setting cmd=1 field3 with field9=universe settings)
1624
- // Even app's bytes: 4a 0a 08 00 10 00 18 01 20 00 28 01
1625
- // = field 9 (universe), {1:0, 2:0, 3:1, 4:0, 5:1}
1626
- var univW = ProtobufWriter()
1627
- univW.writeInt32Field(1, 1) // DeviceReceiveInfo
1628
- univW.writeInt32Field(2, self.sendManager.nextMagicRandom())
1629
- univW.writeMessageField(
1630
- 3,
1631
- Data([
1632
- 0x4A, 0x0A, // field 9, length 10
1633
- 0x08, 0x00, // unitFormat=0
1634
- 0x10, 0x00, // distanceUnit=0
1635
- 0x18, UInt8(self.dashboardHalfDayFormat()), // timeFormat / halfDayFormat
1636
- 0x20, 0x00, // dateFormat=0
1637
- 0x28, UInt8(self.dashboardTemperatureUnit()), // temperatureUnit
1638
- ])
1639
- )
1640
- self.sendG2SettingCommand(univW.data)
1641
-
1642
- // 1. gesture_ctrl init (field1=0, field2=magicRandom)
1643
- var gestureInitW = ProtobufWriter()
1644
- gestureInitW.writeInt32Field(1, 0)
1645
- gestureInitW.writeInt32Field(2, self.sendManager.nextMagicRandom())
1646
- self.sendGestureCtrlCommand(gestureInitW.data)
1647
-
1648
- // 2. ui_setting_app (0x0C) — query (cmd=2, field4={settingInfoType=1, autoBrightnessLevel=0})
1649
- var uiSettW = ProtobufWriter()
1650
- uiSettW.writeInt32Field(1, 2) // cmd = DeviceReceiveRequest
1651
- uiSettW.writeInt32Field(2, self.sendManager.nextMagicRandom())
1652
- uiSettW.writeMessageField(4, Data([0x08, 0x01, 0x10, 0x00])) // {1:1, 2:0}
1653
- self.sendToGlasses(
1654
- self.sendManager.buildPackets(
1655
- serviceId: 0x0C, payload: uiSettW.data, reserveFlag: true
1656
- )
1657
- )
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
1658
1758
 
1659
- // 3. teleprompter (0x10) — config (cmd=1, field3={1:4})
1660
- // var teleW = ProtobufWriter()
1661
- // teleW.writeInt32Field(1, 1)
1662
- // teleW.writeInt32Field(2, self.sendManager.nextMagicRandom())
1663
- // teleW.writeMessageField(3, Data([0x08, 0x04])) // {1:4}
1664
- // self.sendToGlasses(
1665
- // self.sendManager.buildPackets(
1666
- // serviceId: 0x10, payload: teleW.data, reserveFlag: true
1667
- // )
1668
- // )
1669
-
1670
- // // 4. EvenHub CTRL on service 0x81 (cmd=1, empty field3)
1671
- // var ehCtrlW = ProtobufWriter()
1672
- // ehCtrlW.writeInt32Field(1, 1)
1673
- // ehCtrlW.writeInt32Field(2, self.sendManager.nextMagicRandom())
1674
- // ehCtrlW.writeMessageField(3, Data())
1675
- // self.sendEvenHubCtrlCommand(ehCtrlW.data)
1676
-
1677
- // // 5. calendar (0x04) — config
1678
- // var calW = ProtobufWriter()
1679
- // calW.writeInt32Field(1, 1)
1680
- // calW.writeInt32Field(2, self.sendManager.nextMagicRandom())
1681
- // calW.writeMessageField(
1682
- // 3, Data([0x08, 0x01, 0x10, 0x01, 0x18, 0x05, 0x28, 0x01])
1683
- // )
1684
- // self.sendToGlasses(
1685
- // self.sendManager.buildPackets(
1686
- // serviceId: 0x04, payload: calW.data, reserveFlag: true
1687
- // )
1688
- // )
1689
-
1690
- // 6. Dashboard init (0x01) — display settings
1691
- // halfDayFormat: 1 = 12h, 0 = 24h
1692
- // temperatureUnit: 1 = Celsius (metric), 2 = Fahrenheit (imperial)
1693
- var dashDisplayW = ProtobufWriter()
1694
- dashDisplayW.writeInt32Field(1, 4) // displayMode
1695
- dashDisplayW.writeInt32Field(2, 3) // statusDisplayCount
1696
- dashDisplayW.writeMessageField(3, Data([1, 2, 3])) // statusDisplayOrder
1697
- dashDisplayW.writeInt32Field(4, 4) // widgetDisplayCount
1698
- // WidgetType: 1=News, 2=Stock, 3=Schedule, 4=Quicklist, 5=Health
1699
- dashDisplayW.writeMessageField(5, Data([3, 1, 2, 4, 5])) // widgetDisplayOrder: Schedule, News, Stock, Quicklist
1700
- dashDisplayW.writeInt32Field(6, self.dashboardHalfDayFormat()) // halfDayFormat
1701
- dashDisplayW.writeInt32Field(7, self.dashboardTemperatureUnit()) // temperatureUnit
1702
-
1703
- var dashRecvW = ProtobufWriter()
1704
- dashRecvW.writeMessageField(2, dashDisplayW.data)
1705
-
1706
- var dashPkgW = ProtobufWriter()
1707
- dashPkgW.writeInt32Field(1, 2) // Dashboard_Receive
1708
- dashPkgW.writeInt32Field(2, self.sendManager.nextMagicRandom())
1709
- dashPkgW.writeMessageField(4, dashRecvW.data)
1710
- self.sendDashboardCommand(dashPkgW.data)
1711
-
1712
- // 7. Dashboard REQUEST_NEWS_INFO (cmd=5, field7={1:1})
1713
- // var dashNewsReqW = ProtobufWriter()
1714
- // dashNewsReqW.writeInt32Field(1, 5) // REQUEST_NEWS_INFO
1715
- // dashNewsReqW.writeInt32Field(2, self.sendManager.nextMagicRandom())
1716
- // dashNewsReqW.writeMessageField(7, Data([0x08, 0x01])) // {1:1}
1717
- // self.sendDashboardCommand(dashNewsReqW.data)
1718
-
1719
- // // 8. Gesture control list via g2_setting
1720
- // var gestListW = ProtobufWriter()
1721
- // gestListW.writeInt32Field(1, 1) // DeviceReceiveInfo
1722
- // gestListW.writeInt32Field(2, self.sendManager.nextMagicRandom())
1723
- // // field 3 with field 10 (gestureControlList): 3 items, all app_unable
1724
- // let gestureCtrlPayload = Data([
1725
- // 0x52, 0x18, // field 10, length 24
1726
- // 0x0A, 0x06, 0x08, 0x00, 0x10, 0x00, 0x18, 0x00, // item 1
1727
- // 0x0A, 0x06, 0x08, 0x00, 0x10, 0x01, 0x18, 0x00, // item 2
1728
- // 0x0A, 0x06, 0x08, 0x00, 0x10, 0x02, 0x18, 0x00, // item 3
1729
- // ])
1730
- // gestListW.writeMessageField(3, gestureCtrlPayload)
1731
- // self.sendG2SettingCommand(gestListW.data)
1732
-
1733
- // // 9. Dashboard APP_REQUEST_NEWS_INFO (cmd=7, field9={1:1})
1734
- // var dashAppNewsW = ProtobufWriter()
1735
- // dashAppNewsW.writeInt32Field(1, 7) // APP_REQUEST_NEWS_INFO
1736
- // dashAppNewsW.writeInt32Field(2, self.sendManager.nextMagicRandom())
1737
- // dashAppNewsW.writeMessageField(9, Data([0x08, 0x01])) // {1:1}
1738
- // self.sendDashboardCommand(dashAppNewsW.data)
1739
-
1740
- Bridge.log("G2: Sent full Even-compatible init sequence")
1741
- }
1759
+ var dashRecvW = ProtobufWriter()
1760
+ dashRecvW.writeMessageField(2, dashDisplayW.data)
1742
1761
 
1743
- // Start heartbeats after auth
1744
- self.startHeartbeats()
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)
1745
1767
 
1746
- Task { await self.reconnectionManager.stop() }
1747
- Bridge.log("G2: Auth sequence complete, glasses ready")
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
+ }
1748
1820
 
1749
- // Set device_name so DeviceManager can save it for reconnection
1750
- if let peripheralName = self.rightPeripheral?.name
1751
- ?? self.leftPeripheral?.name,
1752
- let serialNumber = self.deviceNameToSerialNumber[peripheralName]
1753
- {
1754
- DeviceStore.shared.apply("bluetooth", "device_name", serialNumber)
1755
- Bridge.log("G2: Set device_name to \(serialNumber)")
1756
- }
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)
1757
1827
 
1758
- // Set bluetooth name and device model for Device Info page
1759
- let btName =
1760
- self.rightPeripheral?.name
1761
- ?? self.leftPeripheral?.name ?? ""
1762
- DeviceStore.shared.apply("glasses", "bluetoothName", btName)
1763
- DeviceStore.shared.apply("glasses", "deviceModel", DeviceTypes.G2)
1828
+ self.setFullyConnected()
1764
1829
 
1765
- self.setFullyConnected()
1830
+ // connnect a controller if we have one:
1831
+ self.connectController()
1766
1832
 
1767
- // connnect a controller if we have one:
1768
- self.connectController()
1833
+ // Query version + battery info from glasses
1834
+ self.requestDeviceInfo()
1769
1835
 
1770
- // Query version + battery info from glasses
1771
- self.requestDeviceInfo()
1836
+ // send dashboard menu if we have stored items
1837
+ self.sendMenuApps()
1772
1838
 
1773
- // send dashboard menu if we have stored items
1774
- self.sendMenuApps()
1839
+ // order the calendar (Schedule) widget first on the dashboard
1840
+ self.setCalendarWidgetFirst()
1775
1841
 
1776
- // send calendar events
1777
- let calendarEvents = DeviceStore.shared.get("bluetooth", "calendar_events") as? [[String: Any]] ?? []
1778
- self.sendCalendarEvents(calendarEvents)
1779
- }
1780
- }
1781
- }
1842
+ // send calendar events
1843
+ let calendarEvents =
1844
+ DeviceStore.shared.get("bluetooth", "calendar_events") as? [[String: Any]] ?? []
1845
+ self.sendCalendarEvents(calendarEvents)
1782
1846
  }
1783
1847
 
1784
1848
  // MARK: - Heartbeats
@@ -1787,7 +1851,7 @@ class G2: NSObject, SGCManager {
1787
1851
  heartbeatTask?.cancel()
1788
1852
  heartbeatTask = Task { [weak self] in
1789
1853
  while !Task.isCancelled {
1790
- try? await Task.sleep(nanoseconds: 5_000_000_000)
1854
+ try? await Task.sleep(nanoseconds: 10_000_000_000)
1791
1855
  guard !Task.isCancelled else { break }
1792
1856
  await MainActor.run {
1793
1857
  self?.sendEvenHubHeartbeat()
@@ -1862,55 +1926,112 @@ class G2: NSObject, SGCManager {
1862
1926
 
1863
1927
  // MARK: - SGCManager: Display Control
1864
1928
 
1865
- 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 {
1866
1948
  // Bridge.log("G2: sendTextWall(\(text.prefix(50))...)")
1867
1949
 
1868
1950
  // ignore events while the ER dashboard is open:
1869
- 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
1870
1953
  if useNativeDashboard && dashboardShowing > 0 {
1871
1954
  return
1872
1955
  }
1873
1956
 
1874
- if text.isEmpty {
1875
- 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)
1876
1990
  return
1877
1991
  }
1878
1992
 
1879
- if !pageCreated || !pageHasTextContainer {
1880
- // Need to create/rebuild page with a text container
1881
- // Bridge.log("G2: sendTextWall() - creating page with text container")
1882
- createPageWithText(text)
1883
- } else {
1884
- // Bridge.log("G2: sendTextWall() - updating text container")
1885
- updateText(text)
1886
- }
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()
1887
2000
  }
1888
2001
 
1889
- func sendDoubleTextWall(_ top: String, _ bottom: String) {
2002
+ func sendDoubleTextWall(_ top: String, _ bottom: String) async {
2003
+ Bridge.log("G2: sendDoubleTextWall() - top: \(top), bottom: \(bottom)")
1890
2004
  // G2 doesn't have native double text wall, combine them
1891
- let combined = top + "\n" + bottom
1892
- sendTextWall(combined)
2005
+ let combined = top + "\n\n" + bottom
2006
+ await sendTextWall(combined)
1893
2007
  }
1894
2008
 
1895
2009
  func clearDisplay() {
1896
2010
  Bridge.log("G2: clearDisplay()")
1897
2011
  // Don't shutdown the EvenHub page — that kills audio streaming too.
1898
2012
  // Instead, just clear the text content by sending a space.
1899
- // if pageCreated {
1900
- // let msg = EvenHubProto.shutdownMessage()
1901
- // sendEvenHubCommand(msg)
1902
- // pageCreated = false
1903
- // currentTextContent = ""
1904
- // }
1905
- if pageCreated {
1906
- 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()
1907
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()
1908
2031
  }
1909
2032
 
1910
2033
  /// Send BMP data to an image container via fragmented updateImageRawData
1911
- private func sendImageData(containerID: Int32, containerName: String, bmpData: Data) async
1912
- -> Bool
1913
- {
2034
+ private func sendImageData(containerID: Int32, containerName: String, bmpData: Data) async {
1914
2035
  let fragmentSize = 4096
1915
2036
  imageSessionCounter += 1
1916
2037
  let sessionId = imageSessionCounter
@@ -1918,9 +2039,13 @@ class G2: NSObject, SGCManager {
1918
2039
  var fragmentIndex: Int32 = 0
1919
2040
  var offset = 0
1920
2041
 
2042
+ Bridge.log(
2043
+ "G2: sendImageData(\(containerName)) - \(fragmentIndex) fragments, \(bmpData.count) bytes"
2044
+ )
2045
+
1921
2046
  while offset < bmpData.count {
1922
2047
  let end = min(offset + fragmentSize, bmpData.count)
1923
- let fragment = bmpData[offset ..< end]
2048
+ let fragment = bmpData[offset..<end]
1924
2049
 
1925
2050
  let msg = EvenHubProto.updateImageRawDataMessage(
1926
2051
  containerID: containerID,
@@ -1936,13 +2061,8 @@ class G2: NSObject, SGCManager {
1936
2061
 
1937
2062
  fragmentIndex += 1
1938
2063
  offset = end
1939
- try? await Task.sleep(nanoseconds: 200_000_000) // 200ms between fragments
2064
+ try? await Task.sleep(nanoseconds: 200_000_000) // 200ms between fragments
1940
2065
  }
1941
-
1942
- Bridge.log(
1943
- "G2: sendImageData(\(containerName)) - \(fragmentIndex) fragments, \(bmpData.count) bytes"
1944
- )
1945
- return true
1946
2066
  }
1947
2067
 
1948
2068
  /// Display a bitmap inside a positioned image container.
@@ -1954,65 +2074,67 @@ class G2: NSObject, SGCManager {
1954
2074
  /// the page is rebuilt before the image is sent.
1955
2075
  ///
1956
2076
  /// Omitted params default to a 100x100 container in the top-left corner.
1957
- func displayBitmap(base64ImageData: String, x: Int32? = nil, y: Int32? = nil, width: Int32? = nil, height: Int32? = nil) async -> Bool {
1958
- currentBitmapBase64 = base64ImageData
1959
- currentTextContent = ""
1960
-
2077
+ func displayBitmap(
2078
+ base64ImageData: String, x: Int32? = nil, y: Int32? = nil, width: Int32? = nil,
2079
+ height: Int32? = nil
2080
+ ) async -> Bool {
1961
2081
  let rx = x ?? G2.defaultImgContainer.x
1962
2082
  let ry = y ?? G2.defaultImgContainer.y
1963
2083
  let rw = width ?? G2.defaultImgContainer.width
1964
2084
  let rh = height ?? G2.defaultImgContainer.height
1965
2085
 
1966
- guard let rawData = Data(base64Encoded: base64ImageData) else {
1967
- Bridge.log("G2: displayBitmap() - failed to decode base64")
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 {
1968
2090
  return false
1969
2091
  }
1970
2092
 
1971
- // Create the startup page lazily, seeded with the default top-left container.
1972
- if !startupPageCreated {
1973
- imageContainers = [
1974
- ImgContainer(
1975
- id: imageContainerIDPool[0],
1976
- x: G2.defaultImgContainer.x, y: G2.defaultImgContainer.y,
1977
- width: G2.defaultImgContainer.width, height: G2.defaultImgContainer.height
1978
- )
1979
- ]
1980
- createPageWithText("")
1981
- Bridge.log("G2: displayBitmap() - startup page created, waiting 1s before sending image data...")
1982
- try? await Task.sleep(nanoseconds: 1_000_000_000)
1983
- }
1984
-
1985
- // Reuse an existing container if the rect matches exactly; otherwise add a new one.
1986
- let container: ImgContainer
1987
- if let existing = imageContainers.first(where: { $0.matches(x: rx, y: ry, width: rw, height: rh) }) {
1988
- container = existing
1989
- Bridge.log("G2: displayBitmap() - reusing container \(existing.id) for rect \(rx),\(ry) \(rw)x\(rh)")
1990
- } else {
1991
- container = addImageContainer(x: rx, y: ry, width: rw, height: rh)
1992
- Bridge.log("G2: displayBitmap() - added container \(container.id) for rect \(rx),\(ry) \(rw)x\(rh), rebuilding page")
1993
- rebuildPage()
1994
- try? await Task.sleep(nanoseconds: 1_000_000_000) // settle before sending image data
2093
+ guard let rawData = Data(base64Encoded: base64ImageData) else {
2094
+ Bridge.log("G2: failed to decode base64")
2095
+ return false
1995
2096
  }
1996
2097
 
1997
- guard let bmpData = convertToG2Bmp(rawData, containerWidth: Int(container.width), containerHeight: Int(container.height))
2098
+ guard
2099
+ let bmpData = convertToG2Bmp(rawData, containerWidth: Int(rw), containerHeight: Int(rh))
1998
2100
  else {
1999
- Bridge.log("G2: displayBitmap() - failed to convert image to BMP")
2101
+ Bridge.log("G2: failed to convert image to BMP")
2000
2102
  return false
2001
2103
  }
2002
2104
 
2003
- let success = await sendImageData(
2004
- containerID: container.id, containerName: container.name, bmpData: bmpData
2005
- )
2006
- if !success {
2007
- Bridge.log("G2: displayBitmap() - failed sending image data")
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)"
2114
+ )
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
2123
+ } else {
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"
2127
+ )
2128
+ await rebuildPage()
2008
2129
  }
2009
- Bridge.log("G2: displayBitmap() - image sent to container \(container.id), \(bmpData.count) bytes")
2010
- return success
2130
+ return true
2011
2131
  }
2012
2132
 
2013
2133
  /// Add a new image container for `rect`, evicting the oldest when the list is full (max 4).
2014
2134
  /// Returns the newly tracked container (with an assigned ID from the pool).
2015
- private func addImageContainer(x: Int32, y: Int32, width: Int32, height: Int32) -> ImgContainer {
2135
+ private func addImageContainer(x: Int32, y: Int32, width: Int32, height: Int32, bmpData: Data)
2136
+ -> ImgContainer
2137
+ {
2016
2138
  // Evict the oldest container when at capacity, freeing its ID for reuse.
2017
2139
  if imageContainers.count >= imageContainerIDPool.count {
2018
2140
  let evicted = imageContainers.removeFirst()
@@ -2021,14 +2143,70 @@ class G2: NSObject, SGCManager {
2021
2143
  // Pick the lowest free ID from the pool.
2022
2144
  let usedIDs = Set(imageContainers.map { $0.id })
2023
2145
  let id = imageContainerIDPool.first { !usedIDs.contains($0) } ?? imageContainerIDPool[0]
2024
- let container = ImgContainer(id: id, x: x, y: y, width: width, height: height)
2146
+ let container = ImgContainer(
2147
+ id: id, x: x, y: y, width: width, height: height, bmpData: bmpData)
2025
2148
  imageContainers.append(container)
2026
2149
  return container
2027
2150
  }
2028
2151
 
2029
- /// Rebuild the page from the current text + image container list.
2030
- private func rebuildPage() {
2031
- createPageWithText(currentTextContent)
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)")
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
+ }
2179
+
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()
2185
+
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"
2192
+ )
2193
+ await sendImageData(
2194
+ containerID: container.id, containerName: container.name, bmpData: container.bmpData
2195
+ )
2196
+ try? await Task.sleep(nanoseconds: 300_000_000) // 300ms between containers
2197
+ }
2198
+
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
+ // }
2032
2210
  }
2033
2211
 
2034
2212
  /// Upscale BMP pixel data by 2x (200x100 → 400x200) using nearest-neighbor
@@ -2041,7 +2219,7 @@ class G2: NSObject, SGCManager {
2041
2219
  return nil
2042
2220
  }
2043
2221
 
2044
- 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
2045
2223
  let pixelDataOffset = headerSize
2046
2224
 
2047
2225
  let dstWidth = srcWidth * 2
@@ -2074,7 +2252,7 @@ class G2: NSObject, SGCManager {
2074
2252
  dst.appendLittleEndian(UInt32(0))
2075
2253
 
2076
2254
  // --- Color Table (same 16-entry grayscale) ---
2077
- for i in 0 ..< 16 {
2255
+ for i in 0..<16 {
2078
2256
  let val = UInt8(i * 17)
2079
2257
  dst.append(contentsOf: [val, val, val, 0])
2080
2258
  }
@@ -2082,12 +2260,12 @@ class G2: NSObject, SGCManager {
2082
2260
  // --- Pixel Data (nearest-neighbor 2x upscale) ---
2083
2261
  // BMP is bottom-up, so row 0 = bottom of image
2084
2262
  // Each dst row maps to srcRow = dstRow / 2
2085
- for dstRow in 0 ..< dstHeight {
2263
+ for dstRow in 0..<dstHeight {
2086
2264
  let srcRow = dstRow / 2
2087
2265
  let srcRowOffset = pixelDataOffset + srcRow * srcPaddedRowSize
2088
2266
  var rowBuf = [UInt8](repeating: 0, count: dstPaddedRowSize)
2089
2267
 
2090
- for dstCol in 0 ..< dstWidth {
2268
+ for dstCol in 0..<dstWidth {
2091
2269
  let srcCol = dstCol / 2
2092
2270
 
2093
2271
  // Read 4-bit nibble from source
@@ -2136,9 +2314,9 @@ class G2: NSObject, SGCManager {
2136
2314
  let offsetX = (containerWidth - scaledW) / 2
2137
2315
  let offsetY = (containerHeight - scaledH) / 2
2138
2316
 
2139
- Bridge.log(
2140
- "G2: convertToG2Bmp - input \(srcWidth)x\(srcHeight) → scaled \(scaledW)x\(scaledH) in \(containerWidth)x\(containerHeight)"
2141
- )
2317
+ // Bridge.log(
2318
+ // "G2: convertToG2Bmp - input \(srcWidth)x\(srcHeight) → scaled \(scaledW)x\(scaledH) in \(containerWidth)x\(containerHeight)"
2319
+ // )
2142
2320
 
2143
2321
  // Render to 8-bit grayscale at the CONTAINER size (not scaled size)
2144
2322
  guard
@@ -2162,7 +2340,7 @@ class G2: NSObject, SGCManager {
2162
2340
  ctx.draw(cgImage, in: CGRect(x: offsetX, y: offsetY, width: scaledW, height: scaledH))
2163
2341
 
2164
2342
  guard let renderedImage = ctx.makeImage(),
2165
- let pixels = renderedImage.dataProvider?.data as Data?
2343
+ let pixels = renderedImage.dataProvider?.data as Data?
2166
2344
  else {
2167
2345
  Bridge.log("G2: convertToG2Bmp - failed to get pixel data")
2168
2346
  return nil
@@ -2184,8 +2362,8 @@ class G2: NSObject, SGCManager {
2184
2362
  /// BMP rows are stored bottom-up. Each row is padded to a 4-byte boundary.
2185
2363
  private func build4BitBmp(grayscalePixels: Data, width: Int, height: Int) -> Data? {
2186
2364
  // 4-bit: 2 pixels per byte, rows padded to 4-byte boundary
2187
- let bytesPerRow4bit = (width + 1) / 2 // ceil(width / 2)
2188
- 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
2189
2367
  let pixelDataSize = paddedRowSize * height
2190
2368
 
2191
2369
  // BMP file header (14 bytes) + DIB header (40 bytes) + color table (16 * 4 = 64 bytes)
@@ -2195,46 +2373,46 @@ class G2: NSObject, SGCManager {
2195
2373
  var bmp = Data(capacity: fileSize)
2196
2374
 
2197
2375
  // --- BMP File Header (14 bytes) ---
2198
- bmp.append(contentsOf: [0x42, 0x4D]) // "BM" signature
2199
- bmp.appendLittleEndian(UInt32(fileSize)) // File size
2200
- bmp.appendLittleEndian(UInt16(0)) // Reserved1
2201
- bmp.appendLittleEndian(UInt16(0)) // Reserved2
2202
- 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
2203
2381
 
2204
2382
  // --- DIB Header (BITMAPINFOHEADER, 40 bytes) ---
2205
- bmp.appendLittleEndian(UInt32(40)) // DIB header size
2206
- bmp.appendLittleEndian(Int32(width)) // Width
2207
- bmp.appendLittleEndian(Int32(height)) // Height (positive = bottom-up)
2208
- bmp.appendLittleEndian(UInt16(1)) // Color planes
2209
- bmp.appendLittleEndian(UInt16(4)) // Bits per pixel (4-bit)
2210
- bmp.appendLittleEndian(UInt32(0)) // Compression (none)
2211
- bmp.appendLittleEndian(UInt32(pixelDataSize)) // Image size
2212
- bmp.appendLittleEndian(Int32(2835)) // X pixels/meter (~72 DPI)
2213
- bmp.appendLittleEndian(Int32(2835)) // Y pixels/meter
2214
- bmp.appendLittleEndian(UInt32(16)) // Colors used
2215
- 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)
2216
2394
 
2217
2395
  // --- Color Table (16 entries, 4 bytes each: B, G, R, 0) ---
2218
- for i in 0 ..< 16 {
2219
- let val = UInt8(i * 17) // 0, 17, 34, ... 255 (evenly spaced grayscale)
2220
- 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
2221
2399
  }
2222
2400
 
2223
2401
  // --- Pixel Data (bottom-up rows, 4-bit packed) ---
2224
2402
  let rowBytes = [UInt8](repeating: 0, count: paddedRowSize)
2225
- for row in 0 ..< height {
2403
+ for row in 0..<height {
2226
2404
  // BMP is bottom-up: row 0 in BMP = last row of image
2227
2405
  let srcRow = height - 1 - row
2228
2406
  let srcOffset = srcRow * width
2229
2407
  var rowBuf = rowBytes
2230
2408
 
2231
- for col in 0 ..< width {
2409
+ for col in 0..<width {
2232
2410
  let pixelIndex = srcOffset + col
2233
2411
  guard pixelIndex < grayscalePixels.count else { continue }
2234
2412
 
2235
2413
  // Map 8-bit grayscale (0-255) to 4-bit index (0-15)
2236
2414
  let gray8 = grayscalePixels[pixelIndex]
2237
- let index4 = gray8 >> 4 // divide by 16
2415
+ let index4 = gray8 >> 4 // divide by 16
2238
2416
 
2239
2417
  let bytePos = col / 2
2240
2418
  if col % 2 == 0 {
@@ -2248,9 +2426,9 @@ class G2: NSObject, SGCManager {
2248
2426
  bmp.append(contentsOf: rowBuf)
2249
2427
  }
2250
2428
 
2251
- Bridge.log(
2252
- "G2: build4BitBmp - \(bmp.count) bytes (header=\(headerSize), pixels=\(pixelDataSize), rows=\(paddedRowSize)x\(height))"
2253
- )
2429
+ // Bridge.log(
2430
+ // "G2: build4BitBmp - \(bmp.count) bytes (header=\(headerSize), pixels=\(pixelDataSize), rows=\(paddedRowSize)x\(height))"
2431
+ // )
2254
2432
  return bmp
2255
2433
  }
2256
2434
 
@@ -2263,8 +2441,6 @@ class G2: NSObject, SGCManager {
2263
2441
  let msg = EvenHubProto.shutdownMessage()
2264
2442
  sendEvenHubCommand(msg)
2265
2443
  pageCreated = false
2266
- pageHasTextContainer = false
2267
- currentTextContent = ""
2268
2444
  currentBitmapBase64 = ""
2269
2445
  DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak self] in
2270
2446
  guard let self = self else { return }
@@ -2286,20 +2462,20 @@ class G2: NSObject, SGCManager {
2286
2462
 
2287
2463
  func sendDashboardDisplaySettings() {
2288
2464
  var dashDisplayW = ProtobufWriter()
2289
- dashDisplayW.writeInt32Field(1, 4) // displayMode
2290
- dashDisplayW.writeInt32Field(2, 3) // statusDisplayCount
2291
- dashDisplayW.writeMessageField(3, Data([1, 2, 3])) // statusDisplayOrder
2292
- dashDisplayW.writeInt32Field(4, 4) // widgetDisplayCount
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
2293
2469
  // WidgetType: 1=News, 2=Stock, 3=Schedule, 4=Quicklist, 5=Health
2294
2470
  dashDisplayW.writeMessageField(5, Data([3, 1, 2, 4, 5]))
2295
- dashDisplayW.writeInt32Field(6, dashboardHalfDayFormat()) // halfDayFormat
2296
- dashDisplayW.writeInt32Field(7, dashboardTemperatureUnit()) // temperatureUnit
2471
+ dashDisplayW.writeInt32Field(6, dashboardHalfDayFormat()) // halfDayFormat
2472
+ dashDisplayW.writeInt32Field(7, dashboardTemperatureUnit()) // temperatureUnit
2297
2473
 
2298
2474
  var dashRecvW = ProtobufWriter()
2299
2475
  dashRecvW.writeMessageField(2, dashDisplayW.data)
2300
2476
 
2301
2477
  var dashPkgW = ProtobufWriter()
2302
- dashPkgW.writeInt32Field(1, 2) // Dashboard_Receive
2478
+ dashPkgW.writeInt32Field(1, 2) // Dashboard_Receive
2303
2479
  dashPkgW.writeInt32Field(2, sendManager.nextMagicRandom())
2304
2480
  dashPkgW.writeMessageField(4, dashRecvW.data)
2305
2481
  sendDashboardCommand(dashPkgW.data)
@@ -2366,8 +2542,8 @@ class G2: NSObject, SGCManager {
2366
2542
  let total = Int32(events.count)
2367
2543
  for (i, ev) in events.enumerated() {
2368
2544
  guard let title = ev["title"] as? String,
2369
- let time = ev["time"] as? String,
2370
- let endTs = ev["endDate"] as? Double
2545
+ let time = ev["time"] as? String,
2546
+ let endTs = ev["endDate"] as? Double
2371
2547
  else { continue }
2372
2548
  let location = ev["location"] as? String
2373
2549
  sendCalendarEvent(
@@ -2420,14 +2596,36 @@ class G2: NSObject, SGCManager {
2420
2596
 
2421
2597
  // MARK: - Private Display Helpers
2422
2598
 
2423
- private func createPageWithText(_ text: String) {
2424
- let tc = EvenHubProto.textContainerProperty(
2425
- x: 0, y: 0, width: 576, height: 288,
2426
- borderWidth: 0, borderColor: 0, borderRadius: 0,
2427
- paddingLength: 4, containerID: textContainerID,
2428
- containerName: "text-main", isEventCapture: true,
2429
- content: text
2430
- )
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
+ }
2431
2629
 
2432
2630
  // Build the page's image containers from the live tracked list.
2433
2631
  let imageContainerProps: [Data] = imageContainers.map { c in
@@ -2438,19 +2636,18 @@ class G2: NSObject, SGCManager {
2438
2636
  }
2439
2637
 
2440
2638
  let msg: Data
2441
- if !startupPageCreated {
2442
- Bridge.log("G2: createPageWithText - using createPageMessage (first time)")
2639
+ if !pageCreated {
2640
+ Bridge.log("G2: createPageWithContainers() - using createPageMessage (first time)")
2443
2641
  msg = EvenHubProto.createPageMessage(
2444
- textContainers: [tc],
2642
+ textContainers: textContainerProps,
2445
2643
  imageContainers: imageContainerProps,
2446
2644
  magicRandom: sendManager.nextMagicRandom(),
2447
2645
  appId: activeMenuAppId
2448
2646
  )
2449
- startupPageCreated = true
2450
2647
  } else {
2451
- Bridge.log("G2: createPageWithText - using rebuildPageMessage")
2648
+ Bridge.log("G2: createPageWithContainers() - using rebuildPageMessage")
2452
2649
  msg = EvenHubProto.rebuildPageMessage(
2453
- textContainers: [tc],
2650
+ textContainers: textContainerProps,
2454
2651
  imageContainers: imageContainerProps,
2455
2652
  magicRandom: sendManager.nextMagicRandom(),
2456
2653
  appId: activeMenuAppId
@@ -2458,21 +2655,6 @@ class G2: NSObject, SGCManager {
2458
2655
  }
2459
2656
  sendEvenHubCommand(msg)
2460
2657
  pageCreated = true
2461
- pageHasTextContainer = true
2462
- currentTextContent = text
2463
- currentBitmapBase64 = ""
2464
- }
2465
-
2466
- private func updateText(_ text: String) {
2467
- let msg = EvenHubProto.updateTextMessage(
2468
- containerID: textContainerID,
2469
- contentOffset: 0,
2470
- contentLength: Int32(text.utf8.count),
2471
- content: text
2472
- )
2473
- queueEvenHubCommand(msg)
2474
- currentTextContent = text
2475
- currentBitmapBase64 = ""
2476
2658
  }
2477
2659
 
2478
2660
  private func queueEvenHubCommand(_ payload: Data) {
@@ -2501,6 +2683,13 @@ class G2: NSObject, SGCManager {
2501
2683
  sendEvenHubCommand(toSend)
2502
2684
  }
2503
2685
 
2686
+ private func restartMicIfAlreadyEnabled() {
2687
+ let currentEnabled = DeviceStore.shared.get("glasses", "micEnabled") as? Bool ?? false
2688
+ if currentEnabled {
2689
+ restartMic()
2690
+ }
2691
+ }
2692
+
2504
2693
  func restartMic() {
2505
2694
  // if already enabled, set to disabled, then send enabled after 500ms:
2506
2695
  DeviceStore.shared.apply("glasses", "micEnabled", true)
@@ -2508,13 +2697,14 @@ class G2: NSObject, SGCManager {
2508
2697
  sendEvenHubCommand(msg)
2509
2698
  DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak self] in
2510
2699
  guard let self = self else { return }
2511
- let useNativeDashboard = DeviceStore.shared.get("bluetooth", "use_native_dashboard") as? Bool ?? false
2512
- 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)")
2513
2703
  if useNativeDashboard && dashboardShowing > 0 {
2514
2704
  return
2515
2705
  }
2516
- if !pageCreated || !pageHasTextContainer {
2517
- DeviceManager.shared.sendCurrentState() // should re-create the page if needed
2706
+ if !pageCreated {
2707
+ DeviceManager.shared.sendCurrentState() // should re-create the page if needed
2518
2708
  }
2519
2709
  let msg = EvenHubProto.audioControlMessage(enable: true)
2520
2710
  self.sendEvenHubCommand(msg)
@@ -2601,7 +2791,6 @@ class G2: NSObject, SGCManager {
2601
2791
  rightAuthenticated = false
2602
2792
  startupPageCreated = false
2603
2793
  pageCreated = false
2604
- pageHasTextContainer = false
2605
2794
  dashboardShowing = 0
2606
2795
  heartbeatCounter = 0
2607
2796
  DeviceStore.shared.apply("glasses", "connected", false)
@@ -2707,7 +2896,9 @@ class G2: NSObject, SGCManager {
2707
2896
  _ skillId: Int32, skillParam: Int32 = 0,
2708
2897
  text: String = "", streamEnable: Int32 = 1, fTextEnd: Int32 = 1
2709
2898
  ) {
2710
- Bridge.log("G2: triggerSkill(\(skillId), skillParam=\(skillParam), text=\"\(text)\", streamEnable=\(streamEnable), fTextEnd=\(fTextEnd))")
2899
+ Bridge.log(
2900
+ "G2: triggerSkill(\(skillId), skillParam=\(skillParam), text=\"\(text)\", streamEnable=\(streamEnable), fTextEnd=\(fTextEnd))"
2901
+ )
2711
2902
  let payload = EvenAIProto.triggerSkill(
2712
2903
  magicRandom: sendManager.nextMagicRandom(),
2713
2904
  skillId: skillId,
@@ -2726,64 +2917,109 @@ class G2: NSObject, SGCManager {
2726
2917
  /// 3. SKILL{skillId=NOTIFICATION, skillParam=show, ...} — dispatches the intent
2727
2918
  /// The SKILL step alone is ignored by firmware; the preceding ENTER+ASK
2728
2919
  /// supply the session context that lets the glasses act on the SKILL.
2729
- func showNotificationsPanel() {
2920
+ func showNotificationsPanel() async {
2730
2921
  Bridge.log("G2: showNotificationsPanel()")
2731
- Task { @MainActor [weak self] in
2732
- guard let self = self else { return }
2733
2922
 
2734
- let enterPayload = EvenAIProto.aiCtrl(
2735
- magicRandom: self.sendManager.nextMagicRandom(),
2736
- status: 2 // EVEN_AI_ENTER
2737
- )
2738
- self.sendEvenAICommand(enterPayload)
2923
+ let enterPayload = EvenAIProto.aiCtrl(
2924
+ magicRandom: sendManager.nextMagicRandom(),
2925
+ status: 2 // EVEN_AI_ENTER
2926
+ )
2927
+ sendEvenAICommand(enterPayload)
2739
2928
 
2740
- try? await Task.sleep(nanoseconds: 400_000_000)
2741
- let askPayload = EvenAIProto.aiAsk(
2742
- magicRandom: self.sendManager.nextMagicRandom(),
2743
- text: " ",
2744
- streamEnable: 0
2745
- )
2746
- self.sendEvenAICommand(askPayload)
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)
2747
2936
 
2748
- try? await Task.sleep(nanoseconds: 400_000_000)
2749
- self.triggerSkill(
2750
- 3, skillParam: 1, // NOTIFICATION, show
2751
- text: " ",
2752
- streamEnable: 1, fTextEnd: 1
2753
- )
2754
- }
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
+ )
2755
2943
  }
2756
2944
 
2757
2945
  func dbg1() {
2758
- showNotificationsPanel()
2946
+ setCalendarWidgetFirst()
2947
+ // toggleHeyEven()
2759
2948
  }
2760
2949
 
2950
+ private var compassRunning = false
2951
+
2761
2952
  func dbg2() {
2762
- Bridge.log("G2: dbg2()")
2953
+ // compassRunning.toggle()
2954
+ // Bridge.log("G2: dbg2() — \(compassRunning ? "start" : "stop") compass")
2955
+ // if compassRunning {
2956
+ // startCompass()
2957
+ // } else {
2958
+ // stopCompass()
2959
+ // }
2960
+ }
2763
2961
 
2764
- // createPageWithText("test1")
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
+ }
2765
2975
 
2766
- // let tc = EvenHubProto.textContainerProperty(
2767
- // x: 0, y: 0, width: 576, height: 288,
2768
- // borderWidth: 0, borderColor: 0, borderRadius: 0,
2769
- // paddingLength: 4, containerID: textContainerID,
2770
- // containerName: "text-main2", isEventCapture: true,
2771
- // content: "test-dbg1"
2772
- // )
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
+ }
2773
2987
 
2774
- // let msg: Data
2775
- // Bridge.log("G2: dbg2 - sending createPageMessage()")
2776
- // msg = EvenHubProto.createPageMessage(
2777
- // textContainers: [tc], magicRandom: sendManager.nextMagicRandom(),
2778
- // appId: nil)
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
+ }
2779
3007
 
2780
- // sendEvenHubCommand(msg)
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
+ }
2781
3016
 
2782
- // // update the text
2783
- // Bridge.log("G2: sendTextWall() - updating text container")
2784
- // updateText("test2")
2785
- let currentDepth = DeviceStore.shared.get("bluetooth", "dashboard_depth") as? Int ?? 0
2786
- setDashboardDepthOnly(currentDepth)
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
+ }
2787
3023
  }
2788
3024
 
2789
3025
  // MARK: - SGCManager: Device Control
@@ -2812,10 +3048,41 @@ class G2: NSObject, SGCManager {
2812
3048
  requestDeviceInfo()
2813
3049
  }
2814
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
+
2815
3082
  func setDashboardMenu(_ items: [[String: Any]]) {
2816
3083
  let menuItems = items.compactMap { dict -> MenuProto.MenuItem? in
2817
3084
  guard let name = dict["name"] as? String,
2818
- let packageName = dict["packageName"] as? String
3085
+ let packageName = dict["packageName"] as? String
2819
3086
  else { return nil }
2820
3087
  let running = dict["running"] as? Bool ?? false
2821
3088
  return MenuProto.MenuItem(packageName: packageName, name: name, running: running)
@@ -2875,7 +3142,8 @@ class G2: NSObject, SGCManager {
2875
3142
 
2876
3143
  func requestPhoto(
2877
3144
  _: String, appId _: String, size _: String?, webhookUrl _: String?, authToken _: String?,
2878
- compress _: String?, flash _: Bool, save _: Bool, sound _: Bool, exposureTimeNs _: Double?, iso _: Int?
3145
+ compress _: String?, flash _: Bool, save _: Bool, sound _: Bool, exposureTimeNs _: Double?,
3146
+ iso _: Int?
2879
3147
  ) {}
2880
3148
  func startVideoRecording(requestId _: String, save _: Bool, flash _: Bool, sound _: Bool) {}
2881
3149
  func startStream(_: [String: Any]) {}
@@ -2976,7 +3244,7 @@ class G2: NSObject, SGCManager {
2976
3244
  centralManager!.scanForPeripherals(
2977
3245
  withServices: nil,
2978
3246
  options: [
2979
- CBCentralManagerScanOptionAllowDuplicatesKey: false,
3247
+ CBCentralManagerScanOptionAllowDuplicatesKey: false
2980
3248
  ]
2981
3249
  )
2982
3250
  return true
@@ -2994,7 +3262,7 @@ class G2: NSObject, SGCManager {
2994
3262
  }
2995
3263
 
2996
3264
  guard let leftUUID = leftGlassUUID(forSN: DEVICE_SEARCH_ID),
2997
- let rightUUID = rightGlassUUID(forSN: DEVICE_SEARCH_ID)
3265
+ let rightUUID = rightGlassUUID(forSN: DEVICE_SEARCH_ID)
2998
3266
  else { return false }
2999
3267
 
3000
3268
  let knownLeft = centralManager?.retrievePeripherals(withIdentifiers: [leftUUID])
@@ -3035,8 +3303,8 @@ class G2: NSObject, SGCManager {
3035
3303
  // a different service than our primary one, and retrieveConnectedPeripherals only
3036
3304
  // returns peripherals whose services match.
3037
3305
  let serviceUUIDs: [CBUUID] = [
3038
- G2BLE.SERVICE_UUID, // EvenHub: 00002760-...-0000
3039
- 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
3040
3308
  ]
3041
3309
  var devices: [CBPeripheral] = []
3042
3310
  for svc in serviceUUIDs {
@@ -3064,8 +3332,8 @@ class G2: NSObject, SGCManager {
3064
3332
  // Extract XX (the numeric ID between G2_ and _L_/_R_)
3065
3333
  let pattern = "G2_(\\d+)_"
3066
3334
  guard let regex = try? NSRegularExpression(pattern: pattern),
3067
- let match = regex.firstMatch(in: name, range: NSRange(name.startIndex..., in: name)),
3068
- 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)
3069
3337
  else {
3070
3338
  return nil
3071
3339
  }
@@ -3074,7 +3342,7 @@ class G2: NSObject, SGCManager {
3074
3342
 
3075
3343
  // MARK: - Incoming Data Handling
3076
3344
 
3077
- private func handleNotifyData(_ data: Data, from peripheral: CBPeripheral) {
3345
+ private func handleNotifyData(_ data: Data, from peripheral: CBPeripheral) async {
3078
3346
  // Distinguish left vs right peripheral so multi-packet reassembly doesn't collide
3079
3347
  let sourceKey = peripheral === leftPeripheral ? "L" : "R"
3080
3348
  guard let result = receiveManager.handlePacket(data, sourceKey: sourceKey) else { return }
@@ -3095,16 +3363,79 @@ class G2: NSObject, SGCManager {
3095
3363
  case ServiceID.dashboard.rawValue:
3096
3364
  handleDashboardResponse(result.payload)
3097
3365
  case ServiceID.gestureCtrl.rawValue:
3098
- 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)
3099
3371
  case ServiceID.evenHubCtrl.rawValue:
3100
3372
  handleEvenHubCtrlResponse(result.payload)
3101
3373
  default:
3102
3374
  Bridge.log(
3103
- "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())"
3376
+ )
3377
+ }
3378
+ }
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())"
3104
3396
  )
3105
3397
  }
3106
3398
  }
3107
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
+
3108
3439
  private func handleEvenHubResponse(_ payload: Data) {
3109
3440
  // Parse evenhub_main_msg_ctx: field 1 = Cmd (varint), field 13 = DevEvent (submessage)
3110
3441
  var reader = ProtobufReader(payload)
@@ -3159,6 +3490,16 @@ class G2: NSObject, SGCManager {
3159
3490
  }
3160
3491
  }
3161
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
+
3162
3503
  // Log unhandled EvenHub commands (helps debug menu selection and stock dashboard interactions)
3163
3504
  // Bridge.log(
3164
3505
  // "G2: EvenHub response cmd=\(cmdValue), \(payload.count) bytes, fields=\(Array(fields.keys).sorted())"
@@ -3177,10 +3518,7 @@ class G2: NSObject, SGCManager {
3177
3518
  Bridge.log(
3178
3519
  "G2: WARN: Glasses shutdown our EvenHub page — resetting page state"
3179
3520
  )
3180
- startupPageCreated = false
3181
3521
  pageCreated = false
3182
- pageHasTextContainer = false
3183
- currentTextContent = ""
3184
3522
  }
3185
3523
  }
3186
3524
  if let errorCode = resFields[8] as? Int32 {
@@ -3197,10 +3535,7 @@ class G2: NSObject, SGCManager {
3197
3535
  // If glasses sent a shutdown (cmd=9/10), our page is gone — reset state
3198
3536
  if cmdValue == 9 || cmdValue == 10 {
3199
3537
  Bridge.log("G2: ERROR: Glasses shutdown our EvenHub page — resetting page state")
3200
- startupPageCreated = false
3201
3538
  pageCreated = false
3202
- pageHasTextContainer = false
3203
- currentTextContent = ""
3204
3539
  }
3205
3540
  }
3206
3541
  }
@@ -3229,6 +3564,37 @@ class G2: NSObject, SGCManager {
3229
3564
  }
3230
3565
  }
3231
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
+
3232
3598
  private func handleTouchEvent(_ devEventData: Data) {
3233
3599
  // Parse SendDeviceEvent: field 1=ListEvent, field 2=TextEvent, field 3=SysEvent
3234
3600
  var reader = ProtobufReader(devEventData)
@@ -3247,6 +3613,19 @@ class G2: NSObject, SGCManager {
3247
3613
  if let sysData = fields[3] as? Data {
3248
3614
  var sysReader = ProtobufReader(sysData)
3249
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
+
3250
3629
  var eventType: OsEventType? = nil
3251
3630
  var eventSource: Int32? = nil
3252
3631
  if let normalType = sysFields[1] as? Int32 {
@@ -3286,7 +3665,8 @@ class G2: NSObject, SGCManager {
3286
3665
  // trigger dashboard:
3287
3666
  let isHeadUp = DeviceStore.shared.get("glasses", "headUp") as? Bool ?? false
3288
3667
 
3289
- 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
3290
3670
  if useNativeDashboard {
3291
3671
  showDashboard()
3292
3672
  } else {
@@ -3305,7 +3685,6 @@ class G2: NSObject, SGCManager {
3305
3685
 
3306
3686
  // toggle head up:
3307
3687
  // DeviceStore.shared.apply("glasses", "headUp", true)
3308
- // runDashboardSequence()
3309
3688
  }
3310
3689
 
3311
3690
  // if eventType == .foregroundEnter {
@@ -3319,14 +3698,8 @@ class G2: NSObject, SGCManager {
3319
3698
  // System exit: glasses killed our EvenHub page (user opened menu or another app)
3320
3699
  // Reset page state and re-create the page to reclaim EvenHub focus
3321
3700
  if eventType == .systemExit || eventType == .abnormalExit {
3322
- let savedText = currentTextContent
3323
- let savedBitmap = currentBitmapBase64
3324
3701
  // Bridge.log("G2: System exit detected")
3325
- startupPageCreated = false
3326
3702
  pageCreated = false
3327
- pageHasTextContainer = false
3328
- currentTextContent = ""
3329
- currentBitmapBase64 = ""
3330
3703
  // Firmware kills the mic on system exit; re-arm it if it should be on
3331
3704
  DeviceStore.shared.apply("glasses", "micEnabled", false)
3332
3705
  DeviceManager.shared.updateMicState()
@@ -3348,7 +3721,7 @@ class G2: NSObject, SGCManager {
3348
3721
  var textReader = ProtobufReader(textData)
3349
3722
  let textFields = textReader.parseFields()
3350
3723
  if let eventTypeRaw = textFields[3] as? Int32,
3351
- let eventType = OsEventType(rawValue: eventTypeRaw)
3724
+ let eventType = OsEventType(rawValue: eventTypeRaw)
3352
3725
  {
3353
3726
  guard let gestureName = mapEventTypeToGesture(eventType) else {
3354
3727
  Bridge.log("G2: no gesture mapping for \(eventType) \(textFields)")
@@ -3389,7 +3762,8 @@ class G2: NSObject, SGCManager {
3389
3762
  case .foregroundEnter: return "foreground_enter"
3390
3763
  case .foregroundExit: return "foreground_exit"
3391
3764
  case .systemExit: return "system_exit"
3392
- 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
3393
3767
  }
3394
3768
  }
3395
3769
 
@@ -3412,15 +3786,15 @@ class G2: NSObject, SGCManager {
3412
3786
 
3413
3787
  // if the data is just a heartbeat, ignore it:
3414
3788
  if let cmdValue = fields[1] as? Int32,
3415
- cmdValue == DevCfgCommandId.baseConnHeartBeat.rawValue
3789
+ cmdValue == DevCfgCommandId.baseConnHeartBeat.rawValue
3416
3790
  {
3417
3791
  return
3418
3792
  }
3419
3793
  // Bridge.log("G2: DevSettings response cmdValue=\(cmdValue)")
3420
3794
 
3421
- Bridge.log(
3422
- "G2: DevSettings response: \(data.prefix(32).map { String(format: "%02X", $0) }.joined(separator: ":"))"
3423
- )
3795
+ // Bridge.log(
3796
+ // "G2: DevSettings response: \(data.prefix(32).map { String(format: "%02X", $0) }.joined(separator: ":"))"
3797
+ // )
3424
3798
 
3425
3799
  // RING_CONNECT_INFO response (cmd 6)
3426
3800
  if cmdValue == DevCfgCommandId.ringConnectInfo.rawValue {
@@ -3436,7 +3810,7 @@ class G2: NSObject, SGCManager {
3436
3810
  // Bridge.log("G2: Ring connection status: connStat=\(connStat)")
3437
3811
 
3438
3812
  // Bridge.log("G2: RingConnectInfo: \(fields)")
3439
- if let ringData = fields[5] as? Data { // field 5 = ringInfo
3813
+ if let ringData = fields[5] as? Data { // field 5 = ringInfo
3440
3814
  var ringReader = ProtobufReader(ringData)
3441
3815
  let ringFields = ringReader.parseFields()
3442
3816
 
@@ -3463,13 +3837,13 @@ class G2: NSObject, SGCManager {
3463
3837
  // DeviceStore.shared.apply("glasses", "controllerSearching", true)
3464
3838
  // }
3465
3839
 
3466
- if let ringData = fields[5] as? Data { // field 5 = ringInfo
3840
+ if let ringData = fields[5] as? Data { // field 5 = ringInfo
3467
3841
  var ringReader = ProtobufReader(ringData)
3468
3842
  let ringFields = ringReader.parseFields()
3469
- let connStatus = ringFields[4] as? Int32 ?? -1 // field 4 = connStatus
3470
- Bridge.log(
3471
- "G2: Ring connection status: connStatus?=\(connStatus))"
3472
- )
3843
+ let connStatus = ringFields[4] as? Int32 ?? -1 // field 4 = connStatus
3844
+ // Bridge.log(
3845
+ // "G2: Ring connection status: connStatus?=\(connStatus))"
3846
+ // )
3473
3847
 
3474
3848
  if connStatus == 22 {
3475
3849
  Bridge.log("G2: Ring disconnected")
@@ -3570,13 +3944,13 @@ class G2: NSObject, SGCManager {
3570
3944
 
3571
3945
  // Software versions
3572
3946
  if let leftVer = fields[5] as? Data,
3573
- let leftVersion = String(data: leftVer, encoding: .utf8)
3947
+ let leftVersion = String(data: leftVer, encoding: .utf8)
3574
3948
  {
3575
3949
  // Bridge.log("G2: Left firmware: \(leftVersion)")
3576
3950
  DeviceStore.shared.apply("glasses", "leftFirmwareVersion", leftVersion)
3577
3951
  }
3578
3952
  if let rightVer = fields[6] as? Data,
3579
- let rightVersion = String(data: rightVer, encoding: .utf8)
3953
+ let rightVersion = String(data: rightVer, encoding: .utf8)
3580
3954
  {
3581
3955
  // Bridge.log("G2: Right firmware: \(rightVersion)")
3582
3956
  DeviceStore.shared.apply("glasses", "rightFirmwareVersion", rightVersion)
@@ -3595,7 +3969,7 @@ class G2: NSObject, SGCManager {
3595
3969
 
3596
3970
  private func handleDashboardResponse(_ payload: Data) {
3597
3971
  Bridge.log(
3598
- "G2: dashboard response: \(payload.prefix(32).map { String(format: "%02X", $0) }.joined())"
3972
+ "G2: dashboard response: \(payload.map { String(format: "%02X", $0) }.joined())"
3599
3973
  )
3600
3974
  var reader = ProtobufReader(payload)
3601
3975
  let fields = reader.parseFields()
@@ -3614,13 +3988,13 @@ class G2: NSObject, SGCManager {
3614
3988
  // AppRespondToDashboard: field1=packageId, field2=flag (0=success)
3615
3989
  if cmd == 3 {
3616
3990
  var appRespW = ProtobufWriter()
3617
- appRespW.writeInt32Field(1, packageId) // packageId
3618
- appRespW.writeInt32Field(2, 0) // flag = APP_RECEIVED_SUCCESS
3991
+ appRespW.writeInt32Field(1, packageId) // packageId
3992
+ appRespW.writeInt32Field(2, 0) // flag = APP_RECEIVED_SUCCESS
3619
3993
 
3620
3994
  var pkgW = ProtobufWriter()
3621
- pkgW.writeInt32Field(1, 4) // commandId = APP_RECEIVE
3995
+ pkgW.writeInt32Field(1, 4) // commandId = APP_RECEIVE
3622
3996
  pkgW.writeInt32Field(2, magicRandom)
3623
- pkgW.writeMessageField(5, appRespW.data) // field5 = appRespond
3997
+ pkgW.writeMessageField(5, appRespW.data) // field5 = appRespond
3624
3998
  sendDashboardCommand(pkgW.data)
3625
3999
  }
3626
4000
  }
@@ -3632,7 +4006,7 @@ class G2: NSObject, SGCManager {
3632
4006
  )
3633
4007
  }
3634
4008
 
3635
- private func handleGestureCtrl(_ data: Data) {
4009
+ private func handleGestureCtrl(_ data: Data) async {
3636
4010
  // gesture_ctrl (service 0x0D): foreground lifecycle signals from glasses
3637
4011
  // (informational only — log if needed for debugging)
3638
4012
  // log first few bytes of the response:
@@ -3640,12 +4014,11 @@ class G2: NSObject, SGCManager {
3640
4014
  // "G2: gesture_ctrl response: \(data.map { String(format: "%02X", $0) }.joined())"
3641
4015
  // )
3642
4016
  // Bridge.log("G2: gesture_ctrl response:")
3643
-
3644
4017
 
3645
4018
  // Dedup: L and R peripherals both deliver this event, so debounce or
3646
4019
  let timestamp = Int64(Date().timeIntervalSince1970 * 1000)
3647
4020
  if lastGestureCtrlTimestamp != nil && timestamp - lastGestureCtrlTimestamp! < 500 {
3648
- Bridge.log("G2: gesture_ctrl dedup")
4021
+ // Bridge.log("G2: gesture_ctrl dedup")
3649
4022
  return
3650
4023
  }
3651
4024
  lastGestureCtrlTimestamp = timestamp
@@ -3654,27 +4027,31 @@ class G2: NSObject, SGCManager {
3654
4027
  // so we need to revive it:
3655
4028
  if data == Data([0x08, 0x01, 0x1A, 0x00]) {
3656
4029
  Bridge.log("G2: dashboard closed / shutdown - dashboardShowing=\(dashboardShowing)")
3657
- 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
3658
4032
  if !useNativeDashboard {
3659
- // make sure the container exists:
3660
- DeviceManager.shared.sendCurrentState()
3661
- // re-send mic on / if it's enabled:
3662
- let micEnabled = DeviceStore.shared.get("glasses", "micEnabled") as? Bool ?? false
3663
- if micEnabled {
3664
- 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
+ }
3665
4043
  }
3666
- // reset the text container (different from clearDisplay())
3667
- // sendTextWall(" ")
3668
- // createPageWithText(" ")
4044
+ return
3669
4045
  } else {
3670
4046
  // if we aren't trying to show the dashboard
3671
4047
  // then we need to turn the mic back on and display the mentra main page:
3672
4048
  if dashboardShowing <= 1 {
3673
4049
  dashboardShowing = 0
3674
- // make sure the container exists:
3675
- DeviceManager.shared.sendCurrentState()
4050
+ // rebuild state:
4051
+ await rebuildState()
3676
4052
  // set the mic back on if it should be on
3677
- let micEnabled = DeviceStore.shared.get("glasses", "micEnabled") as? Bool ?? false
4053
+ let micEnabled =
4054
+ DeviceStore.shared.get("glasses", "micEnabled") as? Bool ?? false
3678
4055
  if micEnabled {
3679
4056
  restartMic()
3680
4057
  }
@@ -3707,7 +4084,7 @@ class G2: NSObject, SGCManager {
3707
4084
 
3708
4085
  private var lastAudioFrame: Data?
3709
4086
 
3710
- private func handleAudioData(_ data: Data) {
4087
+ private func handleAudioData(_ data: Data) async {
3711
4088
  // G2 audio arrives on AUDIO_NOTIFY characteristic
3712
4089
  // Format: ~200+ byte chunks, use first 200 bytes, split into 40-byte LC3 frames
3713
4090
  // Each frame: LC3, 16kHz, mono, 10ms, 40 bytes
@@ -3737,7 +4114,7 @@ func extractSN(from data: Data) -> String? {
3737
4114
  // where the SN string starts.
3738
4115
 
3739
4116
  // Skip "ER" prefix (2 bytes), read 14 bytes of SN
3740
- let snData = data[2 ..< 16]
4117
+ let snData = data[2..<16]
3741
4118
  return String(data: snData, encoding: .ascii)?
3742
4119
  .replacingOccurrences(
3743
4120
  of: "[\\x00-\\x1F\\x7F]", with: "", options: .regularExpression
@@ -3749,7 +4126,7 @@ func extractSN(from data: Data) -> String? {
3749
4126
  /// Returns "AA:BB:CC:DD:EE:FF" (big-endian, colon-separated).
3750
4127
  func extractMac(from data: Data) -> String? {
3751
4128
  guard data.count >= 22 else { return nil }
3752
- let macLE = data[16 ..< 22]
4129
+ let macLE = data[16..<22]
3753
4130
  return macLE.reversed().map { String(format: "%02X", $0) }.joined(separator: ":")
3754
4131
  }
3755
4132
 
@@ -3773,13 +4150,13 @@ extension G2: CBCentralManagerDelegate {
3773
4150
  ) {
3774
4151
  guard
3775
4152
  let name = peripheral.name ?? advertisementData[CBAdvertisementDataLocalNameKey]
3776
- as? String
4153
+ as? String
3777
4154
  else { return }
3778
4155
 
3779
4156
  // G2 glasses have "Even" prefix and "G2" in name, with _L_ or _R_ for side
3780
4157
  guard name.contains("G2") else { return }
3781
4158
  guard let mfgData = advertisementData[CBAdvertisementDataManufacturerDataKey] as? Data,
3782
- mfgData.count >= 16
4159
+ mfgData.count >= 16
3783
4160
  else { return }
3784
4161
 
3785
4162
  DispatchQueue.main.async { [weak self] in
@@ -3791,7 +4168,9 @@ extension G2: CBCentralManagerDelegate {
3791
4168
  }
3792
4169
  // sn = "S200LACA040040"
3793
4170
  let mfgHex = mfgData.map { String(format: "%02X", $0) }.joined(separator: " ")
3794
- 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
+ )
3795
4174
  self.deviceNameToSerialNumber[name] = serialNumber
3796
4175
 
3797
4176
  // Save MAC per side; ring's advStart needs the left lens MAC.
@@ -3854,7 +4233,9 @@ extension G2: CBCentralManagerDelegate {
3854
4233
  self.setRightGlassUUID(peripheral.identifier, forSN: sn)
3855
4234
  }
3856
4235
  } else {
3857
- 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
+ )
3858
4239
  }
3859
4240
 
3860
4241
  // Discover services - scan for all since we need to find the EvenHub characteristics
@@ -3888,7 +4269,6 @@ extension G2: CBCentralManagerDelegate {
3888
4269
 
3889
4270
  self.startupPageCreated = false
3890
4271
  self.pageCreated = false
3891
- self.pageHasTextContainer = false
3892
4272
  self.dashboardShowing = 0
3893
4273
  DeviceStore.shared.apply("glasses", "connected", false)
3894
4274
  DeviceStore.shared.apply("glasses", "fullyBooted", false)
@@ -3904,7 +4284,9 @@ extension G2: CBCentralManagerDelegate {
3904
4284
  guard let self else { return false }
3905
4285
 
3906
4286
  // Check if already connected
3907
- 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
+ }) {
3908
4290
  Bridge.log("G2: Already connected, stopping reconnection")
3909
4291
  return true
3910
4292
  }
@@ -3996,7 +4378,7 @@ extension G2: CBPeripheralDelegate {
3996
4378
  if self.leftInitialized && self.rightInitialized && !self.authStarted {
3997
4379
  self.authStarted = true
3998
4380
  Bridge.log("G2: Both sides initialized, starting auth sequence")
3999
- self.runAuthSequence()
4381
+ Task { await self.runAuthSequence() }
4000
4382
  }
4001
4383
  }
4002
4384
  }
@@ -4007,15 +4389,15 @@ extension G2: CBPeripheralDelegate {
4007
4389
  ) {
4008
4390
  guard let data = characteristic.value, error == nil else { return }
4009
4391
 
4010
- DispatchQueue.main.async { [weak self] in
4392
+ Task { @MainActor [weak self] in
4011
4393
  guard let self = self else { return }
4012
4394
 
4013
4395
  if characteristic.uuid == G2BLE.AUDIO_NOTIFY {
4014
4396
  // Audio data - forward to mic system
4015
- self.handleAudioData(data)
4397
+ await self.handleAudioData(data)
4016
4398
  } else if characteristic.uuid == G2BLE.CHAR_NOTIFY {
4017
4399
  // Protocol data
4018
- self.handleNotifyData(data, from: peripheral)
4400
+ await self.handleNotifyData(data, from: peripheral)
4019
4401
  }
4020
4402
  }
4021
4403
  }