@mentra/bluetooth-sdk 0.1.13 → 0.1.16

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 (80) hide show
  1. package/README.md +18 -8
  2. package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkModule.kt +48 -48
  3. package/android/src/main/java/com/mentra/bluetoothsdk/Bridge.kt +5 -20
  4. package/android/src/main/java/com/mentra/bluetoothsdk/DeviceManager.kt +360 -269
  5. package/android/src/main/java/com/mentra/bluetoothsdk/DeviceStore.kt +3 -7
  6. package/android/src/main/java/com/mentra/bluetoothsdk/MentraBluetoothSdk.kt +152 -72
  7. package/android/src/main/java/com/mentra/bluetoothsdk/OtaManifest.kt +12 -12
  8. package/android/src/main/java/com/mentra/bluetoothsdk/camera/CameraModels.kt +25 -11
  9. package/android/src/main/java/com/mentra/bluetoothsdk/controllers/ControllerManager.kt +1 -2
  10. package/android/src/main/java/com/mentra/bluetoothsdk/controllers/R1.kt +1 -2
  11. package/android/src/main/java/com/mentra/bluetoothsdk/events/BluetoothEvents.kt +1 -22
  12. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/G1.kt +51 -8
  13. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/G2.kt +1184 -800
  14. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/Mach1.kt +1 -5
  15. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraLive.kt +423 -117
  16. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraNex.kt +4 -6
  17. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/Nimo.kt +2532 -0
  18. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/SGCManager.kt +17 -5
  19. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/Simulated.kt +3 -7
  20. package/android/src/main/java/com/mentra/bluetoothsdk/status/DeviceStatus.kt +0 -6
  21. package/android/src/main/java/com/mentra/bluetoothsdk/streaming/StreamModels.kt +16 -33
  22. package/android/src/main/java/com/mentra/bluetoothsdk/types/DeviceModels.kt +5 -3
  23. package/android/src/main/java/com/mentra/bluetoothsdk/utils/Constants.kt +2 -1
  24. package/android/src/test/java/com/mentra/bluetoothsdk/camera/PhotoRequestTest.kt +60 -1
  25. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/NimoProtocolTest.kt +333 -0
  26. package/build/BluetoothSdk.types.d.ts +50 -24
  27. package/build/BluetoothSdk.types.d.ts.map +1 -1
  28. package/build/BluetoothSdk.types.js +1 -0
  29. package/build/BluetoothSdk.types.js.map +1 -1
  30. package/build/_internal.d.ts +4 -4
  31. package/build/_internal.js +4 -4
  32. package/build/_internal.js.map +1 -1
  33. package/build/_private/BluetoothSdkModule.d.ts +14 -8
  34. package/build/_private/BluetoothSdkModule.d.ts.map +1 -1
  35. package/build/_private/BluetoothSdkModule.js +4 -3
  36. package/build/_private/BluetoothSdkModule.js.map +1 -1
  37. package/build/_private/cameraRequestPayload.d.ts +4 -0
  38. package/build/_private/cameraRequestPayload.d.ts.map +1 -0
  39. package/build/_private/cameraRequestPayload.js +25 -0
  40. package/build/_private/cameraRequestPayload.js.map +1 -0
  41. package/build/_private/photoRequestPayload.d.ts.map +1 -1
  42. package/build/_private/photoRequestPayload.js +11 -3
  43. package/build/_private/photoRequestPayload.js.map +1 -1
  44. package/build/index.d.ts +1 -1
  45. package/build/index.d.ts.map +1 -1
  46. package/build/index.js +70 -61
  47. package/build/index.js.map +1 -1
  48. package/ios/BluetoothSdkModule.swift +51 -28
  49. package/ios/Source/Bridge.swift +4 -16
  50. package/ios/Source/DeviceManager.swift +76 -22
  51. package/ios/Source/DeviceStore.swift +0 -4
  52. package/ios/Source/MentraBluetoothSDK.swift +144 -78
  53. package/ios/Source/OtaManifest.swift +13 -13
  54. package/ios/Source/camera/CameraModels.swift +82 -20
  55. package/ios/Source/controllers/ControllerManager.swift +1 -2
  56. package/ios/Source/controllers/R1.swift +1 -2
  57. package/ios/Source/errors/{BluetoothError.swift → BluetoothSdkError.swift} +1 -1
  58. package/ios/Source/events/BluetoothEvents.swift +5 -27
  59. package/ios/Source/internal/BluetoothAvailability.swift +5 -5
  60. package/ios/Source/sgcs/Frame.swift +0 -2
  61. package/ios/Source/sgcs/G1.swift +52 -4
  62. package/ios/Source/sgcs/G2.swift +643 -204
  63. package/ios/Source/sgcs/Mach1.swift +1 -3
  64. package/ios/Source/sgcs/MentraLive.swift +64 -77
  65. package/ios/Source/sgcs/MentraNex.swift +1 -3
  66. package/ios/Source/sgcs/Nimo.swift +1928 -0
  67. package/ios/Source/sgcs/SGCManager.swift +16 -10
  68. package/ios/Source/sgcs/Simulated.swift +2 -6
  69. package/ios/Source/status/DeviceStatus.swift +0 -8
  70. package/ios/Source/streaming/StreamModels.swift +7 -32
  71. package/ios/Source/stt/STTTools.swift +1 -1
  72. package/ios/Source/types/DeviceModels.swift +5 -0
  73. package/ios/Source/utils/Constants.swift +2 -0
  74. package/package.json +6 -1
  75. package/src/BluetoothSdk.types.ts +54 -28
  76. package/src/_internal.ts +4 -4
  77. package/src/_private/BluetoothSdkModule.ts +20 -11
  78. package/src/_private/cameraRequestPayload.ts +29 -0
  79. package/src/_private/photoRequestPayload.ts +12 -3
  80. package/src/index.ts +77 -67
@@ -1,5 +1,15 @@
1
1
  import Foundation
2
2
 
3
+ func generatedCameraRequestId(_ prefix: String) -> String {
4
+ "\(prefix)-\(Int(Date().timeIntervalSince1970 * 1000))-\(UUID().uuidString.prefix(8))"
5
+ }
6
+
7
+ func nonBlankRequestId(_ requestId: String?) -> String? {
8
+ guard let requestId else { return nil }
9
+ let trimmed = requestId.trimmingCharacters(in: .whitespacesAndNewlines)
10
+ return trimmed.isEmpty ? nil : trimmed
11
+ }
12
+
3
13
  public enum PhotoSize: String {
4
14
  case low
5
15
  case medium
@@ -47,8 +57,8 @@ public enum PhotoCompression: String {
47
57
  case heavy
48
58
  }
49
59
 
50
- public struct ButtonPhotoSettings {
51
- public let size: ButtonPhotoSize?
60
+ public struct PhotoCaptureDefaults {
61
+ public let size: PhotoSize?
52
62
  public let mfnr: Bool?
53
63
  public let zsl: Bool?
54
64
  public let noiseReduction: Bool?
@@ -62,7 +72,7 @@ public struct ButtonPhotoSettings {
62
72
  public let resetCaptureTuning: Bool?
63
73
 
64
74
  public init(
65
- size: ButtonPhotoSize?,
75
+ size: PhotoSize? = nil,
66
76
  mfnr: Bool? = nil,
67
77
  zsl: Bool? = nil,
68
78
  noiseReduction: Bool? = nil,
@@ -89,12 +99,12 @@ public struct ButtonPhotoSettings {
89
99
  self.resetCaptureTuning = resetCaptureTuning
90
100
  }
91
101
 
92
- static func from(params: [String: Any]) -> ButtonPhotoSettings {
93
- let size = (params["size"] as? String).map { ButtonPhotoSize(normalizedRawValue: $0) }
102
+ static func from(params: [String: Any]) -> PhotoCaptureDefaults {
103
+ let size = (params["size"] as? String).map { PhotoSize(normalizedRawValue: $0) }
94
104
  let aeExposureDivisor =
95
105
  optionalIntValue(params, "aeExposureDivisor").flatMap { $0 > 1 ? $0 : nil }
96
106
  let isoCap = optionalIntValue(params, "isoCap").flatMap { $0 > 0 ? $0 : nil }
97
- return ButtonPhotoSettings(
107
+ return PhotoCaptureDefaults(
98
108
  size: size,
99
109
  mfnr: optionalBoolValue(params, "mfnr"),
100
110
  zsl: optionalBoolValue(params, "zsl"),
@@ -111,7 +121,7 @@ public struct ButtonPhotoSettings {
111
121
  }
112
122
  }
113
123
 
114
- public struct ButtonVideoRecordingSettings {
124
+ public struct VideoRecordingDefaults {
115
125
  public let width: Int
116
126
  public let height: Int
117
127
  public let fps: Int
@@ -200,13 +210,13 @@ public struct CameraFovResult: CustomStringConvertible {
200
210
 
201
211
  static func from(ack: SettingsAckEvent, fallback: CameraFov) throws -> CameraFovResult {
202
212
  if ack.status == "error" {
203
- throw BluetoothError(
213
+ throw BluetoothSdkError(
204
214
  code: ack.errorCode ?? "camera_fov_failed",
205
215
  message: ack.errorMessage ?? "Camera FOV request failed."
206
216
  )
207
217
  }
208
218
  if !ack.hardwareApplied {
209
- throw BluetoothError(
219
+ throw BluetoothSdkError(
210
220
  code: "camera_fov_not_applied",
211
221
  message: "Camera FOV was saved but not applied to hardware."
212
222
  )
@@ -223,12 +233,10 @@ public struct CameraFovResult: CustomStringConvertible {
223
233
 
224
234
  public struct PhotoRequest {
225
235
  public let requestId: String
226
- public let appId: String
227
236
  public let size: PhotoSize
228
237
  public let webhookUrl: String?
229
238
  public let authToken: String?
230
239
  public let compress: PhotoCompression?
231
- public let flash: Bool
232
240
  public let save: Bool
233
241
  public let sound: Bool
234
242
  /// Sensor exposure time for this capture only (ns), or nil for auto exposure
@@ -245,13 +253,11 @@ public struct PhotoRequest {
245
253
  public let ispAnalogGain: String?
246
254
 
247
255
  public init(
248
- requestId: String,
249
- appId: String,
256
+ requestId: String? = nil,
250
257
  size: PhotoSize,
251
258
  webhookUrl: String? = nil,
252
259
  authToken: String? = nil,
253
260
  compress: PhotoCompression? = nil,
254
- flash: Bool = true,
255
261
  save: Bool = false,
256
262
  sound: Bool,
257
263
  exposureTimeNs: Double? = nil,
@@ -265,13 +271,11 @@ public struct PhotoRequest {
265
271
  ispDigitalGain: Int? = nil,
266
272
  ispAnalogGain: String? = nil
267
273
  ) {
268
- self.requestId = requestId
269
- self.appId = appId
274
+ self.requestId = nonBlankRequestId(requestId) ?? generatedCameraRequestId("photo")
270
275
  self.size = size
271
276
  self.webhookUrl = webhookUrl
272
277
  self.authToken = authToken
273
278
  self.compress = compress
274
- self.flash = flash
275
279
  self.save = save
276
280
  self.sound = sound
277
281
  self.exposureTimeNs = exposureTimeNs
@@ -334,13 +338,11 @@ public struct PhotoRequest {
334
338
  }
335
339
 
336
340
  return PhotoRequest(
337
- requestId: params["requestId"] as? String ?? "",
338
- appId: params["appId"] as? String ?? "",
341
+ requestId: params["requestId"] as? String,
339
342
  size: PhotoSize(normalizedRawValue: sizeRaw),
340
343
  webhookUrl: params["webhookUrl"] as? String,
341
344
  authToken: (params["authToken"] as? String)?.nilIfBlank,
342
345
  compress: PhotoCompression(rawValue: compressRaw),
343
- flash: params["flash"] as? Bool ?? true,
344
346
  save: (params["save"] as? Bool) ?? (params["saveToGallery"] as? Bool) ?? false,
345
347
  sound: params["sound"] as? Bool ?? true,
346
348
  exposureTimeNs: exposureTimeNs,
@@ -382,6 +384,28 @@ public struct PhotoRequest {
382
384
  json["ispAnalogGain"] = ispAnalogGain
383
385
  }
384
386
  }
387
+
388
+ func withRequestId(_ requestId: String) -> PhotoRequest {
389
+ PhotoRequest(
390
+ requestId: requestId,
391
+ size: size,
392
+ webhookUrl: webhookUrl,
393
+ authToken: authToken,
394
+ compress: compress,
395
+ save: save,
396
+ sound: sound,
397
+ exposureTimeNs: exposureTimeNs,
398
+ iso: iso,
399
+ aeExposureDivisor: aeExposureDivisor,
400
+ isoCap: isoCap,
401
+ noiseReduction: noiseReduction,
402
+ edgeEnhancement: edgeEnhancement,
403
+ mfnr: mfnr,
404
+ zsl: zsl,
405
+ ispDigitalGain: ispDigitalGain,
406
+ ispAnalogGain: ispAnalogGain
407
+ )
408
+ }
385
409
  }
386
410
 
387
411
  private extension String {
@@ -726,6 +750,44 @@ public struct PhotoStatusEvent: CustomStringConvertible {
726
750
  }
727
751
  }
728
752
 
753
+ public struct CameraStatusEvent: CustomStringConvertible {
754
+ public let values: [String: Any]
755
+
756
+ public init(values: [String: Any]) {
757
+ var values = values
758
+ values["type"] = "camera_status"
759
+ self.values = values
760
+ }
761
+
762
+ public var requestId: String {
763
+ stringValue(values, "requestId") ?? ""
764
+ }
765
+
766
+ public var state: String {
767
+ stringValue(values, "state") ?? ""
768
+ }
769
+
770
+ public var timestamp: Int64 {
771
+ if let value = values["timestamp"] as? Int64 { return value }
772
+ if let value = values["timestamp"] as? Int { return Int64(value) }
773
+ if let value = values["timestamp"] as? Double { return Int64(value) }
774
+ if let value = values["timestamp"] as? NSNumber { return value.int64Value }
775
+ return Int64(Date().timeIntervalSince1970 * 1000)
776
+ }
777
+
778
+ public var errorCode: String? {
779
+ stringValue(values, "errorCode")
780
+ }
781
+
782
+ public var errorMessage: String? {
783
+ stringValue(values, "errorMessage")
784
+ }
785
+
786
+ public var description: String {
787
+ "CameraStatusEvent(requestId: \(requestId), state: \(state))"
788
+ }
789
+ }
790
+
729
791
  public struct GalleryStatusEvent: CustomStringConvertible {
730
792
  public let values: [String: Any]
731
793
 
@@ -22,7 +22,7 @@ protocol ControllerManager {
22
22
  func startStream(_ message: [String: Any])
23
23
  func stopStream()
24
24
  func sendStreamKeepAlive(_ message: [String: Any])
25
- func startVideoRecording(requestId: String, save: Bool, flash: Bool, sound: Bool)
25
+ func startVideoRecording(requestId: String, save: Bool, sound: Bool)
26
26
  func stopVideoRecording(requestId: String)
27
27
 
28
28
  // MARK: - Button Settings
@@ -30,7 +30,6 @@ protocol ControllerManager {
30
30
  func sendButtonPhotoSettings()
31
31
  func sendButtonVideoRecordingSettings()
32
32
  func sendButtonMaxRecordingTime()
33
- func sendButtonCameraLedSetting()
34
33
 
35
34
  // MARK: - Display Control
36
35
 
@@ -505,7 +505,7 @@ class R1: NSObject, ControllerManager {
505
505
 
506
506
  func sendJson(_: [String: Any], wakeUp _: Bool, requireAck _: Bool) {}
507
507
  func requestPhoto(_: PhotoRequest) {}
508
- func startVideoRecording(requestId _: String, save _: Bool, flash _: Bool, sound _: Bool) {}
508
+ func startVideoRecording(requestId _: String, save _: Bool, sound _: Bool) {}
509
509
  func stopVideoRecording(requestId _: String) {}
510
510
  func startStream(_: [String: Any]) {}
511
511
  func stopStream() {}
@@ -513,7 +513,6 @@ class R1: NSObject, ControllerManager {
513
513
  func sendButtonPhotoSettings() {}
514
514
  func sendButtonVideoRecordingSettings() {}
515
515
  func sendButtonMaxRecordingTime() {}
516
- func sendButtonCameraLedSetting() {}
517
516
  func setBrightness(_: Int, autoMode _: Bool) {}
518
517
  func clearDisplay() {}
519
518
  func sendTextWall(_: String) {}
@@ -1,6 +1,6 @@
1
1
  import Foundation
2
2
 
3
- public struct BluetoothError: Error, LocalizedError, CustomStringConvertible {
3
+ public struct BluetoothSdkError: Error, LocalizedError, CustomStringConvertible {
4
4
  public let code: String
5
5
  public let message: String
6
6
 
@@ -73,28 +73,6 @@ public struct SpeakingStatusEvent: CustomStringConvertible {
73
73
  }
74
74
  }
75
75
 
76
- public struct OtaUpdateAvailableEvent: CustomStringConvertible {
77
- public let versionCode: Int?
78
- public let versionName: String?
79
- public let updates: [String]
80
- public let totalSize: Int?
81
- public let cacheReady: Bool?
82
- public let values: [String: Any]
83
-
84
- public init(values: [String: Any]) {
85
- versionCode = intValue(values["version_code"])
86
- versionName = stringValue(values, "version_name")
87
- updates = values["updates"] as? [String] ?? []
88
- totalSize = intValue(values["total_size"])
89
- cacheReady = boolValue(values, "cache_ready")
90
- self.values = values
91
- }
92
-
93
- public var description: String {
94
- "OtaUpdateAvailableEvent(versionName: \(versionName ?? "unknown"), updates: \(updates.joined(separator: ",")))"
95
- }
96
- }
97
-
98
76
  public struct OtaStartAckEvent: CustomStringConvertible {
99
77
  public let timestamp: Int?
100
78
  public let values: [String: Any]
@@ -243,12 +221,12 @@ public enum BluetoothEvent: CustomStringConvertible {
243
221
  case hotspotError(HotspotErrorEvent)
244
222
  case photoResponse(PhotoResponseEvent)
245
223
  case photoStatus(PhotoStatusEvent)
224
+ case cameraStatus(CameraStatusEvent)
246
225
  case videoRecordingStatus(VideoRecordingStatusEvent)
247
226
  case mediaUpload(MediaUploadEvent)
248
227
  case rgbLedControlResponse(RgbLedControlResponseEvent)
249
228
  case streamStatus(StreamStatusEvent)
250
229
  case keepAliveAck(KeepAliveAckEvent)
251
- case otaUpdateAvailable(OtaUpdateAvailableEvent)
252
230
  case otaStartAck(OtaStartAckEvent)
253
231
  case otaStatus(OtaStatusEvent)
254
232
  case settingsAck(SettingsAckEvent)
@@ -276,6 +254,8 @@ public enum BluetoothEvent: CustomStringConvertible {
276
254
  event.description
277
255
  case let .photoStatus(event):
278
256
  event.description
257
+ case let .cameraStatus(event):
258
+ event.description
279
259
  case let .videoRecordingStatus(event):
280
260
  event.description
281
261
  case let .mediaUpload(event):
@@ -286,8 +266,6 @@ public enum BluetoothEvent: CustomStringConvertible {
286
266
  event.description
287
267
  case let .keepAliveAck(event):
288
268
  event.description
289
- case let .otaUpdateAvailable(event):
290
- event.description
291
269
  case let .otaStartAck(event):
292
270
  event.description
293
271
  case let .otaStatus(event):
@@ -317,7 +295,7 @@ public protocol MentraBluetoothSDKDelegate: AnyObject {
317
295
  func mentraBluetoothSDK(_ sdk: MentraBluetoothSDK, didReceiveMicLc3 event: MicLc3Event)
318
296
  func mentraBluetoothSDK(_ sdk: MentraBluetoothSDK, didChangeDefaultDevice device: Device?)
319
297
  func mentraBluetoothSDK(_ sdk: MentraBluetoothSDK, didLog message: String)
320
- func mentraBluetoothSDK(_ sdk: MentraBluetoothSDK, didFail error: BluetoothError)
298
+ func mentraBluetoothSDK(_ sdk: MentraBluetoothSDK, didFail error: BluetoothSdkError)
321
299
  }
322
300
 
323
301
  @MainActor
@@ -333,5 +311,5 @@ public extension MentraBluetoothSDKDelegate {
333
311
  func mentraBluetoothSDK(_: MentraBluetoothSDK, didReceiveMicLc3 _: MicLc3Event) {}
334
312
  func mentraBluetoothSDK(_: MentraBluetoothSDK, didChangeDefaultDevice _: Device?) {}
335
313
  func mentraBluetoothSDK(_: MentraBluetoothSDK, didLog _: String) {}
336
- func mentraBluetoothSDK(_: MentraBluetoothSDK, didFail _: BluetoothError) {}
314
+ func mentraBluetoothSDK(_: MentraBluetoothSDK, didFail _: BluetoothSdkError) {}
337
315
  }
@@ -49,27 +49,27 @@ final class BluetoothAvailability: NSObject, CBCentralManagerDelegate {
49
49
  case .poweredOn:
50
50
  return
51
51
  case .poweredOff:
52
- throw BluetoothError(
52
+ throw BluetoothSdkError(
53
53
  code: "bluetooth_powered_off",
54
54
  message: "Turn on phone Bluetooth to \(operation)."
55
55
  )
56
56
  case .unauthorized:
57
- throw BluetoothError(
57
+ throw BluetoothSdkError(
58
58
  code: "bluetooth_unauthorized",
59
59
  message: "Allow Bluetooth access to \(operation)."
60
60
  )
61
61
  case .unsupported:
62
- throw BluetoothError(
62
+ throw BluetoothSdkError(
63
63
  code: "bluetooth_unsupported",
64
64
  message: "This phone does not support Bluetooth."
65
65
  )
66
66
  case .resetting, .unknown:
67
- throw BluetoothError(
67
+ throw BluetoothSdkError(
68
68
  code: "bluetooth_not_ready",
69
69
  message: "Bluetooth is not ready yet. Try again."
70
70
  )
71
71
  @unknown default:
72
- throw BluetoothError(
72
+ throw BluetoothSdkError(
73
73
  code: "bluetooth_unavailable",
74
74
  message: "Bluetooth is unavailable. Try again."
75
75
  )
@@ -75,8 +75,6 @@
75
75
 
76
76
  // func sendButtonMaxRecordingTime(_: Int) {}
77
77
 
78
- // func sendButtonCameraLedSetting() {}
79
-
80
78
  func exit() {}
81
79
 
82
80
  func sendRgbLedControl(requestId: String, packageName _: String?, action _: String, color _: String?, onDurationMs _: Int, offDurationMs _: Int, count _: Int) {
@@ -284,7 +284,7 @@ class G1: NSObject, SGCManager {
284
284
 
285
285
  func sendStreamKeepAlive(_: [String: Any]) {}
286
286
 
287
- func startVideoRecording(requestId _: String, save _: Bool, flash _: Bool, sound _: Bool) {}
287
+ func startVideoRecording(requestId _: String, save _: Bool, sound _: Bool) {}
288
288
 
289
289
  func stopVideoRecording(requestId _: String) {}
290
290
 
@@ -294,8 +294,6 @@ class G1: NSObject, SGCManager {
294
294
 
295
295
  func sendButtonMaxRecordingTime() {}
296
296
 
297
- func sendButtonCameraLedSetting() {}
298
-
299
297
  func sendCameraFovSetting() {}
300
298
 
301
299
  func showDashboard() {}
@@ -787,7 +785,52 @@ class G1: NSObject, SGCManager {
787
785
  await sendTextWall(text)
788
786
  }
789
787
 
788
+ // G1-specific display throttle (300ms, last-wins). G1 firmware can't absorb rapid text-wall
789
+ // updates; coalesce to the latest within a 300ms window. This used to live in the cloud
790
+ // DisplayManager (which fronted G1 before captions moved on-device); it belongs in the G1 SGC
791
+ // because it's a G1 hardware quirk — G2 deliberately does NOT throttle (it must show every
792
+ // caption). The trailing flush always sends the most recent text, so the final caption is never
793
+ // dropped — only intermediate frames within a window are coalesced.
794
+ private var g1TextThrottlePending: String?
795
+ private var g1TextThrottleLastSent: Date = .distantPast
796
+ private var g1TextThrottleScheduled = false
797
+ private let g1TextThrottleWindow: TimeInterval = 0.3
798
+
799
+ /// Drop any pending throttled text-wall flush so it can't later overwrite a newer, non-text
800
+ /// display write (a clear, double-text-wall, or bitmap). The scheduled flush no-ops when
801
+ /// `g1TextThrottlePending` is nil. Called from every G1 display path that bypasses the throttle.
802
+ private func cancelPendingThrottledText() {
803
+ g1TextThrottlePending = nil
804
+ }
805
+
790
806
  func sendTextWall(_ text: String) async {
807
+ let now = Date()
808
+ let sinceLast = now.timeIntervalSince(g1TextThrottleLastSent)
809
+ if sinceLast >= g1TextThrottleWindow {
810
+ // Past the window — send now.
811
+ g1TextThrottleLastSent = now
812
+ g1TextThrottlePending = nil
813
+ await flushTextWall(text)
814
+ } else {
815
+ // Inside the window — keep only the latest and schedule one trailing flush.
816
+ g1TextThrottlePending = text
817
+ if !g1TextThrottleScheduled {
818
+ g1TextThrottleScheduled = true
819
+ let wait = g1TextThrottleWindow - sinceLast
820
+ Task { @MainActor [weak self] in
821
+ try? await Task.sleep(nanoseconds: UInt64(wait * 1_000_000_000))
822
+ guard let self = self else { return }
823
+ self.g1TextThrottleScheduled = false
824
+ guard let pending = self.g1TextThrottlePending else { return }
825
+ self.g1TextThrottlePending = nil
826
+ self.g1TextThrottleLastSent = Date()
827
+ await self.flushTextWall(pending)
828
+ }
829
+ }
830
+ }
831
+ }
832
+
833
+ private func flushTextWall(_ text: String) async {
791
834
  let chunks = textHelper.createTextWallChunks(text)
792
835
  queueChunks(chunks, sleepAfterMs: 10)
793
836
  }
@@ -801,6 +844,7 @@ class G1: NSObject, SGCManager {
801
844
  }
802
845
 
803
846
  func sendDoubleTextWall(_ top: String, _ bottom: String) async {
847
+ cancelPendingThrottledText() // a newer layout supersedes any pending caption text
804
848
  let chunks = textHelper.createDoubleTextWallChunks(textTop: top, textBottom: bottom)
805
849
  queueChunks(chunks, sleepAfterMs: 10)
806
850
 
@@ -1873,6 +1917,7 @@ extension G1 {
1873
1917
  // MARK: - Enhanced BMP Display Methods
1874
1918
 
1875
1919
  func displayBitmap(base64ImageData: String, x _: Int32? = nil, y _: Int32? = nil, width _: Int32? = nil, height _: Int32? = nil) async -> Bool {
1920
+ cancelPendingThrottledText() // a bitmap supersedes any pending caption text
1876
1921
  guard let bmpData = Data(base64Encoded: base64ImageData) else {
1877
1922
  Bridge.log("G1: Failed to decode base64 image data")
1878
1923
  return false
@@ -1887,7 +1932,10 @@ extension G1 {
1887
1932
 
1888
1933
  func clearDisplay() {
1889
1934
  Bridge.log("G1: clearDisplay() - Using space")
1890
- Task { await sendTextWall(" ") }
1935
+ // Bypass the throttle (a clear must always land) and drop any pending caption so it can't
1936
+ // overwrite the clear after the fact.
1937
+ cancelPendingThrottledText()
1938
+ Task { await flushTextWall(" ") }
1891
1939
  }
1892
1940
 
1893
1941
  /// Create a simple test BMP pattern in hex format