@mentra/bluetooth-sdk 0.1.14 → 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 (65) hide show
  1. package/README.md +4 -6
  2. package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkModule.kt +22 -9
  3. package/android/src/main/java/com/mentra/bluetoothsdk/Bridge.kt +5 -20
  4. package/android/src/main/java/com/mentra/bluetoothsdk/DeviceManager.kt +358 -261
  5. package/android/src/main/java/com/mentra/bluetoothsdk/DeviceStore.kt +3 -3
  6. package/android/src/main/java/com/mentra/bluetoothsdk/MentraBluetoothSdk.kt +133 -52
  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 +22 -6
  9. package/android/src/main/java/com/mentra/bluetoothsdk/events/BluetoothEvents.kt +1 -22
  10. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/G1.kt +50 -3
  11. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/G2.kt +1184 -795
  12. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraLive.kt +422 -78
  13. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraNex.kt +3 -4
  14. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/Nimo.kt +2532 -0
  15. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/SGCManager.kt +15 -1
  16. package/android/src/main/java/com/mentra/bluetoothsdk/types/DeviceModels.kt +5 -3
  17. package/android/src/main/java/com/mentra/bluetoothsdk/utils/Constants.kt +2 -1
  18. package/android/src/test/java/com/mentra/bluetoothsdk/camera/PhotoRequestTest.kt +60 -1
  19. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/NimoProtocolTest.kt +333 -0
  20. package/build/BluetoothSdk.types.d.ts +39 -14
  21. package/build/BluetoothSdk.types.d.ts.map +1 -1
  22. package/build/BluetoothSdk.types.js +1 -0
  23. package/build/BluetoothSdk.types.js.map +1 -1
  24. package/build/_internal.d.ts +4 -4
  25. package/build/_internal.js +4 -4
  26. package/build/_internal.js.map +1 -1
  27. package/build/_private/BluetoothSdkModule.d.ts +11 -3
  28. package/build/_private/BluetoothSdkModule.d.ts.map +1 -1
  29. package/build/_private/BluetoothSdkModule.js +5 -0
  30. package/build/_private/BluetoothSdkModule.js.map +1 -1
  31. package/build/_private/cameraRequestPayload.d.ts +4 -0
  32. package/build/_private/cameraRequestPayload.d.ts.map +1 -0
  33. package/build/_private/cameraRequestPayload.js +25 -0
  34. package/build/_private/cameraRequestPayload.js.map +1 -0
  35. package/build/_private/photoRequestPayload.d.ts.map +1 -1
  36. package/build/_private/photoRequestPayload.js +8 -2
  37. package/build/_private/photoRequestPayload.js.map +1 -1
  38. package/build/index.d.ts +1 -1
  39. package/build/index.d.ts.map +1 -1
  40. package/build/index.js +72 -54
  41. package/build/index.js.map +1 -1
  42. package/ios/BluetoothSdkModule.swift +40 -12
  43. package/ios/Source/Bridge.swift +4 -16
  44. package/ios/Source/DeviceManager.swift +72 -12
  45. package/ios/Source/MentraBluetoothSDK.swift +120 -50
  46. package/ios/Source/OtaManifest.swift +13 -13
  47. package/ios/Source/camera/CameraModels.swift +75 -9
  48. package/ios/Source/errors/{BluetoothError.swift → BluetoothSdkError.swift} +1 -1
  49. package/ios/Source/events/BluetoothEvents.swift +5 -27
  50. package/ios/Source/internal/BluetoothAvailability.swift +5 -5
  51. package/ios/Source/sgcs/G1.swift +51 -1
  52. package/ios/Source/sgcs/G2.swift +643 -203
  53. package/ios/Source/sgcs/MentraLive.swift +53 -39
  54. package/ios/Source/sgcs/Nimo.swift +1928 -0
  55. package/ios/Source/sgcs/SGCManager.swift +12 -5
  56. package/ios/Source/stt/STTTools.swift +1 -1
  57. package/ios/Source/types/DeviceModels.swift +5 -0
  58. package/ios/Source/utils/Constants.swift +2 -0
  59. package/package.json +6 -1
  60. package/src/BluetoothSdk.types.ts +42 -17
  61. package/src/_internal.ts +4 -4
  62. package/src/_private/BluetoothSdkModule.ts +18 -2
  63. package/src/_private/cameraRequestPayload.ts +29 -0
  64. package/src/_private/photoRequestPayload.ts +9 -2
  65. package/src/index.ts +76 -58
@@ -1322,6 +1322,56 @@ private class G2ReceiveManager {
1322
1322
  }
1323
1323
  }
1324
1324
 
1325
+ // MARK: - Image ACK box
1326
+
1327
+ /// Thread-safe holder for the single in-flight image-fragment ACK continuation.
1328
+ ///
1329
+ /// Lives OUTSIDE `@MainActor` isolation so the CoreBluetooth notify callback (which runs on the BLE
1330
+ /// dispatch queue) can correlate and resume the awaiting continuation directly, without hopping to
1331
+ /// the main actor. That hop is exactly what caused intermittent "glasses stopped responding": the
1332
+ /// ACK would queue behind a packet burst / heartbeats / the text-queue tick on the main actor and
1333
+ /// miss the timeout window even though it had physically arrived.
1334
+ ///
1335
+ /// All access is guarded by `lock`. Resuming a `CheckedContinuation` from any thread is safe, and
1336
+ /// `arm`/`resolve` clear the slot atomically so neither the duplicate L/R ACK nor the timeout can
1337
+ /// resume the same continuation twice.
1338
+ private final class ImgAckBox {
1339
+ private let lock = NSLock()
1340
+ private var session: Int?
1341
+ private var fragment: Int32?
1342
+ private var cont: CheckedContinuation<Bool, Never>?
1343
+
1344
+ /// Install the awaiting continuation for `(session, fragment)`.
1345
+ func arm(session: Int, fragment: Int32, cont: CheckedContinuation<Bool, Never>) {
1346
+ lock.lock()
1347
+ self.session = session
1348
+ self.fragment = fragment
1349
+ self.cont = cont
1350
+ lock.unlock()
1351
+ }
1352
+
1353
+ /// Resume the continuation if it matches `(session, fragment)`. Returns true if it fired.
1354
+ @discardableResult
1355
+ func resolve(session: Int, fragment: Int32, success: Bool) -> Bool {
1356
+ lock.lock()
1357
+ guard self.session == session, self.fragment == fragment, let c = cont else {
1358
+ lock.unlock()
1359
+ return false
1360
+ }
1361
+ self.session = nil
1362
+ self.fragment = nil
1363
+ self.cont = nil
1364
+ lock.unlock()
1365
+ c.resume(returning: success)
1366
+ return true
1367
+ }
1368
+
1369
+ /// Time out the continuation for `(session, fragment)` (resumes false if still armed).
1370
+ func timeout(session: Int, fragment: Int32) {
1371
+ _ = resolve(session: session, fragment: fragment, success: false)
1372
+ }
1373
+ }
1374
+
1325
1375
  // MARK: - G2 Class (SGCManager implementation)
1326
1376
 
1327
1377
  /// Actor for reconnection logic (matches G1 pattern)
@@ -1405,6 +1455,17 @@ class G2: NSObject, SGCManager {
1405
1455
  private var pairingTimeoutTimer: DispatchWorkItem?
1406
1456
  private var useEvenDashboard = true
1407
1457
  private var dashboardShowing = 0
1458
+ // The 08011A00 gesture_ctrl event is ambiguous: the firmware sends it BOTH when the dashboard
1459
+ // opens (it shuts our page down to take the screen) and when it closes (returns to us). When
1460
+ // showDashboard() runs we set this latch; the next 08011A00 is the OPEN confirm — consume it
1461
+ // WITHOUT recovering (else we rebuild our page and snatch the screen back from the dashboard).
1462
+ // The following 08011A00 is the real CLOSE → recover.
1463
+ private var dashboardOpening = false
1464
+ // Recovery throttle: the firmware spams systemExit + dashboard-close ~1×/sec on its own.
1465
+ // Coalesce so recovery can't storm — one rebuild in flight, one per RECOVERY_DEBOUNCE_MS.
1466
+ private var recoveryInFlight = false
1467
+ private var lastRecoveryRebuildMs: Int64 = 0
1468
+ private let RECOVERY_DEBOUNCE_MS: Int64 = 1500
1408
1469
 
1409
1470
  /// Device search
1410
1471
  var DEVICE_SEARCH_ID = "NOT_SET"
@@ -1459,18 +1520,36 @@ class G2: NSObject, SGCManager {
1459
1520
  private var foregroundObserver: NSObjectProtocol?
1460
1521
  private var startupPageCreated: Bool = false // createStartUpPageContainer can only be called once
1461
1522
  private var pageCreated: Bool = false
1523
+ // Live hardware truth: is the firmware mic actually streaming. DISTINCT from the
1524
+ // glasses/micEnabled DeviceStore flag, which is *intent* (does the user want the mic on).
1525
+ // Cleared on every page teardown (the firmware kills the mic with the page) WITHOUT touching
1526
+ // intent, so recovery can re-arm iff intent still says the mic should be on.
1527
+ private var evenHubMicActive: Bool = false
1462
1528
  private var currentTextContent: String = ""
1463
1529
  private var currentBitmapBase64: String = ""
1464
1530
  private var textContainerID: Int32 = 1
1465
1531
  private var imageSessionCounter: Int = 0
1532
+ /// Lock-protected, non-isolated holder for the in-flight image ACK so the BLE-queue notify
1533
+ /// callback can resolve it without bouncing through the main actor (see [ImgAckBox]).
1534
+ private let imgAckBox = ImgAckBox()
1535
+ /// Background loop that owns ALL display sends (text + images): each pass pushes dirty text
1536
+ /// containers, then dirty image containers one at a time. Because it's the sole sender, exactly
1537
+ /// one `sendImageData` is ever in flight by construction — no serializing lock needed; display
1538
+ /// ops just mark containers dirty and signal `displayDirtySignal`.
1539
+ private var displayReconcileTask: Task<Void, Never>?
1540
+ /// ~100ms ticker that nudges the reconcile loop while idle, so text resends and image retries
1541
+ /// still happen with no new mutations. Just yields into `displayDirtySignal`.
1542
+ private var displayTickTask: Task<Void, Never>?
1543
+ /// Wakes the reconcile loop the instant a container is marked dirty, instead of waiting out the
1544
+ /// idle tick. `signalDisplayDirty()` (and the ticker) yield into this; the loop drains it.
1545
+ private var displayDirtySignal: AsyncStream<Void>.Continuation?
1546
+ private let IMG_ACK_TIMEOUT_NS: UInt64 = 2_000_000_000 // 1000ms timeout (matches Dart host)
1547
+ private let IMG_MAX_ATTEMPTS = 3
1466
1548
  private var heartbeatTask: Task<Void, Never>?
1467
1549
  private var heartbeatCounter: Int = 0
1468
- private var evenHubQueueTask: Task<Void, Never>?
1469
- private var pendingTextMsg: Data?
1470
- private var lastEvenHubMsg: Data?
1471
- private var lastEvenHubResendsRemaining: Int = 0
1550
+ /// How many redundant resends each text update gets (text has no ACK). The reconcile loop sets a
1551
+ /// container's `pendingSends` to `1 + EVEN_HUB_RESEND_COUNT` on change.
1472
1552
  private let EVEN_HUB_RESEND_COUNT: Int = 1
1473
- private let evenHubQueueLock = NSLock()
1474
1553
  private var authStarted: Bool = false
1475
1554
 
1476
1555
  /// Dashboard menu: appId → packageName mapping for selection reverse lookup
@@ -1496,6 +1575,11 @@ class G2: NSObject, SGCManager {
1496
1575
  "img-\(id)"
1497
1576
  }
1498
1577
  var bmpData: Data
1578
+ /// Set true when `bmpData` changes and the new pixels haven't been pushed to the glasses yet.
1579
+ /// The reconcile loop (see `displayReconcileTask`) is the sole sender; it clears this once the
1580
+ /// exact bytes it sent still match the container. Lets every display op be a pure state
1581
+ /// mutation, so only one `sendImageData` is ever in flight (no DisplayMutex needed).
1582
+ var dirty: Bool = false
1499
1583
 
1500
1584
  func matches(x: Int32, y: Int32, width: Int32, height: Int32) -> Bool {
1501
1585
  self.x == x && self.y == y && self.width == width && self.height == height
@@ -1513,6 +1597,10 @@ class G2: NSObject, SGCManager {
1513
1597
  let borderColor: Int32
1514
1598
  let borderRadius: Int32
1515
1599
  let paddingLength: Int32
1600
+ /// Remaining sends the reconcile loop owes this container. Text has no ACK, so each content
1601
+ /// change schedules `1 + EVEN_HUB_RESEND_COUNT` sends (the update + a redundant resend on a
1602
+ /// later tick) as a delivery hedge; the loop sends once and decrements per tick until 0.
1603
+ var pendingSends: Int = 0
1516
1604
  var name: String {
1517
1605
  "text-\(id)"
1518
1606
  }
@@ -1563,15 +1651,83 @@ class G2: NSObject, SGCManager {
1563
1651
 
1564
1652
  // MARK: - BLE Sending
1565
1653
 
1654
+ // Per-side FIFO write queues, drained by a single paced async writer per side.
1655
+ //
1656
+ // We write `.withoutResponse` directly and pace with a small `Task.sleep` between packets —
1657
+ // exactly like G1 (`G1.swift attemptSend`). We deliberately do NOT gate on
1658
+ // `canSendWriteWithoutResponse` / wait for `peripheralIsReady(toSendWriteWithoutResponse:)`:
1659
+ // iOS suppresses that "ready" callback for a backgrounded bluetooth-central app (G2 has no
1660
+ // active AVAudioSession — glasses-mic audio arrives over BLE), so a gated drain DEADLOCKS in the
1661
+ // background: the queue grows for the whole bg window and floods the glasses on resume — the
1662
+ // captions "freeze in bg, then flood" bug. G1 never gates and never floods; this matches it.
1663
+ // The pace replaces the gate's overflow protection (the original reason for gating). Any packet
1664
+ // CoreBluetooth still drops self-heals: text is re-sent (TextContainer.pendingSends) and image
1665
+ // fragments retry on their ACK (`awaitImageAck`).
1666
+ private var leftWriteQueue: [Data] = []
1667
+ private var rightWriteQueue: [Data] = []
1668
+ private var leftDraining = false
1669
+ private var rightDraining = false
1670
+ // Pace between consecutive packets (~G1's chunk pacing). Off any external callback, so the drain
1671
+ // keeps making progress in the background instead of waiting for a callback iOS won't deliver.
1672
+ private let writePaceNanos: UInt64 = 6_000_000
1673
+ // Diagnostic: warn if a side's queue ever backs up (it shouldn't now — the drainer is always
1674
+ // making progress). Rate-limited. Prefixed "BGCAP:" so it's easy to grep/strip after validation.
1675
+ private var bgcapDepthLogAt: Double = 0
1676
+
1566
1677
  private func sendToGlasses(_ packets: [Data], left: Bool = false, right: Bool = true) {
1567
- // Bridge.log("G2: sendToGlasses() - sending \(packets.count) packets first byte: \(packets[0][0])")
1568
- for packet in packets {
1569
- if right, let char = rightWriteChar, let peripheral = rightPeripheral {
1570
- peripheral.writeValue(packet, for: char, type: .withoutResponse)
1678
+ if right {
1679
+ rightWriteQueue.append(contentsOf: packets)
1680
+ startDrain(right: true)
1681
+ }
1682
+ if left {
1683
+ leftWriteQueue.append(contentsOf: packets)
1684
+ startDrain(right: false)
1685
+ }
1686
+ }
1687
+
1688
+ /// Ensure a single paced drainer is running for this side. Idempotent — a second call while one
1689
+ /// is already draining is a no-op (the running loop will pick up the newly-enqueued packets).
1690
+ private func startDrain(right: Bool) {
1691
+ if right {
1692
+ if rightDraining { return }
1693
+ rightDraining = true
1694
+ } else {
1695
+ if leftDraining { return }
1696
+ leftDraining = true
1697
+ }
1698
+ Task { @MainActor [weak self] in
1699
+ await self?.drainLoop(right: right)
1700
+ }
1701
+ }
1702
+
1703
+ /// Drain one side's queue: write each packet directly (`.withoutResponse`), paced by a small
1704
+ /// sleep. No `canSend` gate (see note above). Runs until the queue is empty, then clears the
1705
+ /// per-side flag so the next enqueue restarts it.
1706
+ private func drainLoop(right: Bool) async {
1707
+ while true {
1708
+ guard let peripheral = right ? rightPeripheral : leftPeripheral,
1709
+ let char = right ? rightWriteChar : leftWriteChar
1710
+ else {
1711
+ // No connection for this side; drop pending packets so they can't replay later.
1712
+ if right { rightWriteQueue.removeAll(); rightDraining = false }
1713
+ else { leftWriteQueue.removeAll(); leftDraining = false }
1714
+ return
1715
+ }
1716
+ let depth = (right ? rightWriteQueue : leftWriteQueue).count
1717
+ if depth == 0 {
1718
+ if right { rightDraining = false } else { leftDraining = false }
1719
+ return
1571
1720
  }
1572
- if left, let char = leftWriteChar, let peripheral = leftPeripheral {
1573
- peripheral.writeValue(packet, for: char, type: .withoutResponse)
1721
+ if depth > 20 {
1722
+ let now = Date().timeIntervalSince1970
1723
+ if now - bgcapDepthLogAt >= 1.0 {
1724
+ Bridge.log("BGCAP: g2 write queue depth=\(depth) side=\(right ? "R" : "L") (draining, not blocked)")
1725
+ bgcapDepthLogAt = now
1726
+ }
1574
1727
  }
1728
+ let packet = right ? rightWriteQueue.removeFirst() : leftWriteQueue.removeFirst()
1729
+ peripheral.writeValue(packet, for: char, type: .withoutResponse)
1730
+ try? await Task.sleep(nanoseconds: writePaceNanos)
1575
1731
  }
1576
1732
  }
1577
1733
 
@@ -1859,15 +2015,32 @@ class G2: NSObject, SGCManager {
1859
2015
  }
1860
2016
  }
1861
2017
 
1862
- // EvenHub text command queue: drain the most recent pending updateText every 100ms
1863
- evenHubQueueTask?.cancel()
1864
- evenHubQueueTask = Task { [weak self] in
2018
+ // Display reconcile loop: push any dirty text containers (one send each, with a redundant
2019
+ // resend), then any dirty image containers one at a time. Single sender, so a `sendImageData`
2020
+ // never overlaps another and can't clobber imgAckBox — the invariant DisplayMutex used to
2021
+ // enforce. The loop wakes on each stream element: mutations yield one immediately (instant
2022
+ // reaction), and a ~100ms ticker yields one when idle so periodic work (text resends, image
2023
+ // retries) still runs. The 1-deep buffer coalesces bursts into a single wake.
2024
+ displayReconcileTask?.cancel()
2025
+ displayTickTask?.cancel()
2026
+ // Closure-based initializer (not makeStream, which is iOS 16+). 1-deep newest buffer so a
2027
+ // burst of mutations coalesces into a single wake.
2028
+ var signalContinuation: AsyncStream<Void>.Continuation!
2029
+ let signalStream = AsyncStream<Void>(bufferingPolicy: .bufferingNewest(1)) { continuation in
2030
+ signalContinuation = continuation
2031
+ }
2032
+ displayDirtySignal = signalContinuation
2033
+ displayTickTask = Task { [weak self] in
1865
2034
  while !Task.isCancelled {
1866
2035
  try? await Task.sleep(nanoseconds: 100_000_000)
1867
2036
  guard !Task.isCancelled else { break }
1868
- await MainActor.run {
1869
- self?.drainEvenHubQueue()
1870
- }
2037
+ self?.displayDirtySignal?.yield()
2038
+ }
2039
+ }
2040
+ displayReconcileTask = Task { [weak self] in
2041
+ for await _ in signalStream {
2042
+ if Task.isCancelled { break }
2043
+ await self?.reconcileDisplay()
1871
2044
  }
1872
2045
  }
1873
2046
  }
@@ -1875,13 +2048,19 @@ class G2: NSObject, SGCManager {
1875
2048
  private func stopHeartbeats() {
1876
2049
  heartbeatTask?.cancel()
1877
2050
  heartbeatTask = nil
1878
- evenHubQueueTask?.cancel()
1879
- evenHubQueueTask = nil
1880
- evenHubQueueLock.lock()
1881
- pendingTextMsg = nil
1882
- lastEvenHubMsg = nil
1883
- lastEvenHubResendsRemaining = 0
1884
- evenHubQueueLock.unlock()
2051
+ displayTickTask?.cancel()
2052
+ displayTickTask = nil
2053
+ displayReconcileTask?.cancel()
2054
+ displayReconcileTask = nil
2055
+ displayDirtySignal?.finish()
2056
+ displayDirtySignal = nil
2057
+ }
2058
+
2059
+ /// Wake the reconcile loop now (a container was just marked dirty / had sends scheduled). Cheap
2060
+ /// and idempotent: coalesced by the stream's 1-deep buffer, so a burst of mutations yields at
2061
+ /// most one extra wake.
2062
+ private func signalDisplayDirty() {
2063
+ displayDirtySignal?.yield()
1885
2064
  }
1886
2065
 
1887
2066
  private func sendEvenHubHeartbeat() {
@@ -1945,6 +2124,16 @@ class G2: NSObject, SGCManager {
1945
2124
  await sendTextWall(text)
1946
2125
  }
1947
2126
 
2127
+ func sendPositionedText(
2128
+ _ text: String, x: Int32, y: Int32, width: Int32, height: Int32,
2129
+ borderWidth: Int32, borderRadius: Int32
2130
+ ) async {
2131
+ await sendTextAt(
2132
+ text, x: x, y: y, width: width, height: height,
2133
+ borderWidth: borderWidth, borderRadius: borderRadius
2134
+ )
2135
+ }
2136
+
1948
2137
  func sendTextAt(
1949
2138
  _ text: String, x: Int32? = nil, y: Int32? = nil, width: Int32? = nil, height: Int32? = nil,
1950
2139
  borderWidth: Int32? = nil, borderColor: Int32? = nil, borderRadius: Int32? = nil,
@@ -1963,44 +2152,53 @@ class G2: NSObject, SGCManager {
1963
2152
  let ry = y ?? G2.defaultTextContainer.y
1964
2153
  let rw = width ?? G2.defaultTextContainer.width
1965
2154
  let rh = height ?? G2.defaultTextContainer.height
1966
- let borderWidth = G2.defaultTextContainer.borderWidth
1967
- let borderColor = G2.defaultTextContainer.borderColor
1968
- let borderRadius = G2.defaultTextContainer.borderRadius
1969
- let paddingLength = G2.defaultTextContainer.paddingLength
2155
+ let borderWidth = borderWidth ?? G2.defaultTextContainer.borderWidth
2156
+ let borderColor = borderColor ?? G2.defaultTextContainer.borderColor
2157
+ let borderRadius = borderRadius ?? G2.defaultTextContainer.borderRadius
2158
+ let paddingLength = paddingLength ?? G2.defaultTextContainer.paddingLength
1970
2159
  let content = text.isEmpty ? " " : text
1971
2160
 
1972
- // Reuse an existing container if the rect matches exactly; otherwise add a new one.
1973
- var container: TextContainer
2161
+ // Pure state mutation: update the container's content and schedule its sends; the reconcile
2162
+ // loop (`displayReconcileTask`) does the actual updateText writes. Reuse an existing container
2163
+ // if the rect matches exactly; otherwise add a new one.
1974
2164
  if let i = textContainers.firstIndex(where: {
1975
2165
  $0.matches(
1976
2166
  x: rx, y: ry, width: rw, height: rh, borderWidth: borderWidth,
1977
2167
  borderColor: borderColor, borderRadius: borderRadius, paddingLength: paddingLength)
1978
2168
  }) {
1979
2169
  textContainers[i].content = content
1980
- container = textContainers[i]
1981
- Bridge.log(
1982
- "G2: sendText() - reusing container \(container.id) for rect \(rx),\(ry) \(rw)x\(rh)"
1983
- )
2170
+ textContainers[i].pendingSends = 1 + EVEN_HUB_RESEND_COUNT
2171
+ let container = textContainers[i]
2172
+ // Wake the reconcile loop either way. When the page is live it sends the text;
2173
+ // when the page is down the loop coalesces the burst into a single rebuild (see
2174
+ // reconcileDisplay) instead of one shutdown/rebuild per caption. The container's
2175
+ // content is overwritten in place (last-wins), so a backlog that piled up while
2176
+ // iOS had us suspended collapses to one catch-up render — no flood on resume.
2177
+ signalDisplayDirty()
1984
2178
  if !pageCreated {
1985
- await rebuildPage()
2179
+ Bridge.log(
2180
+ "G2: sendText() - page down, buffering latest content for container \(container.id) (rebuild deferred to reconcile)"
2181
+ )
1986
2182
  return
1987
2183
  }
1988
- let msg = EvenHubProto.updateTextMessage(
1989
- containerID: container.id,
1990
- contentOffset: 0,
1991
- contentLength: Int32(container.content.utf8.count),
1992
- content: container.content
2184
+ Bridge.log(
2185
+ "G2: sendText() - reusing container \(container.id) for rect \(rx),\(ry) \(rw)x\(rh)"
1993
2186
  )
1994
- queueEvenHubCommand(msg)
1995
2187
  return
1996
2188
  }
1997
2189
 
1998
- container = addTextContainer(
2190
+ let container = addTextContainer(
1999
2191
  x: rx, y: ry, width: rw, height: rh, content: content, borderWidth: borderWidth,
2000
2192
  borderColor: borderColor, borderRadius: borderRadius, paddingLength: paddingLength)
2001
2193
  Bridge.log(
2002
2194
  "G2: sendText() - added text container \(container.id) for rect \(rx),\(ry) \(rw)x\(rh), rebuilding page"
2003
2195
  )
2196
+ // New container changes page structure: rebuild it (the rebuild embeds initial content), then
2197
+ // schedule sends so the loop refreshes it.
2198
+ if let j = textContainers.firstIndex(where: { $0.id == container.id }) {
2199
+ textContainers[j].pendingSends = 1 + EVEN_HUB_RESEND_COUNT
2200
+ }
2201
+ signalDisplayDirty()
2004
2202
  await rebuildPage()
2005
2203
  }
2006
2204
 
@@ -2013,60 +2211,131 @@ class G2: NSObject, SGCManager {
2013
2211
 
2014
2212
  func clearDisplay() {
2015
2213
  Bridge.log("G2: clearDisplay()")
2016
- // Don't shutdown the EvenHub pagethat kills audio streaming too.
2017
- // Instead, just clear the text content by sending a space.
2018
-
2019
- // if !pageCreated {
2020
- // Bridge.log("G2: clearDisplay() - page not created")
2021
- // createPageWithContainers()
2022
- // }
2023
-
2024
- // reset the content of all text containers to empty:
2214
+ // Blank the text in place do NOT shut down + rebuild the page. A teardown kills audio
2215
+ // and triggers a firmware systemExit→recovery→rebuild; the cloud sends clearDisplay in
2216
+ // bursts, so that turned into a rebuild storm. The reconcile loop pushes the blanked text.
2025
2217
  for i in textContainers.indices {
2026
2218
  textContainers[i].content = " "
2219
+ textContainers[i].pendingSends = 1 + EVEN_HUB_RESEND_COUNT
2027
2220
  }
2028
- for i in imageContainers.indices {
2221
+ for i in imageContainers.indices where !imageContainers[i].bmpData.isEmpty {
2222
+ // The firmware still shows this container's image; emptying bmpData locally never
2223
+ // reaches it (#3232 dropped the teardown that used to drop empty containers on
2224
+ // rebuild). Empty + dirty tells the reconcile loop to push an all-black frame that
2225
+ // overwrites the image on-glass — the page stays up, so no audio/mic churn.
2029
2226
  imageContainers[i].bmpData = Data()
2227
+ imageContainers[i].dirty = true
2030
2228
  }
2031
- // shutdown the page and then recreate the containers without the content:
2032
- Task { await rebuildPage() }
2229
+ signalDisplayDirty()
2033
2230
  }
2034
2231
 
2035
- /// Send BMP data to an image container via fragmented updateImageRawData
2232
+ /// Send a bitmap to an image container as fragmented updateImageRawData packets.
2233
+ ///
2234
+ /// The glasses reply ONCE per fragment with an ImgResCmd ErrorCode (4=success, 5=failed).
2235
+ /// Each fragment is sent with its own session id, then this awaits that fragment's ACK for up
2236
+ /// to `IMG_ACK_TIMEOUT_NS` before sending the next. A `failed` ACK OR no ACK within the window
2237
+ /// counts as a failure and the entire image is re-sent (fresh sessions) up to
2238
+ /// `IMG_MAX_ATTEMPTS` times. On exhausting all attempts it logs a warning and returns
2239
+ /// (best-effort — callers are unaffected).
2036
2240
  private func sendImageData(containerID: Int32, containerName: String, bmpData: Data) async {
2037
2241
  let fragmentSize = 4096
2038
- imageSessionCounter += 1
2039
- let sessionId = imageSessionCounter
2040
2242
  let totalSize = Int32(bmpData.count)
2041
- var fragmentIndex: Int32 = 0
2042
- var offset = 0
2243
+ let fragmentCount = (bmpData.count + fragmentSize - 1) / fragmentSize
2043
2244
 
2044
- Bridge.log(
2045
- "G2: sendImageData(\(containerName)) - \(fragmentIndex) fragments, \(bmpData.count) bytes"
2046
- )
2245
+ // skip if the image is empty:
2246
+ if bmpData.count == 0 {
2247
+ return
2248
+ }
2047
2249
 
2048
- while offset < bmpData.count {
2049
- let end = min(offset + fragmentSize, bmpData.count)
2050
- let fragment = bmpData[offset..<end]
2051
-
2052
- let msg = EvenHubProto.updateImageRawDataMessage(
2053
- containerID: containerID,
2054
- containerName: containerName,
2055
- mapSessionId: Int32(sessionId),
2056
- mapTotalSize: totalSize,
2057
- compressMode: 0,
2058
- mapFragmentIndex: fragmentIndex,
2059
- mapFragmentPacketSize: Int32(fragment.count),
2060
- mapRawData: Data(fragment)
2061
- )
2062
- sendEvenHubCommand(msg)
2250
+ // Bridge.log(
2251
+ // "G2: sendImageData(\(containerName)) - \(fragmentCount) fragments, \(bmpData.count) bytes"
2252
+ // )
2063
2253
 
2064
- fragmentIndex += 1
2065
- offset = end
2066
- try? await Task.sleep(nanoseconds: 200_000_000) // 200ms between fragments
2254
+ for attempt in 1...IMG_MAX_ATTEMPTS {
2255
+ // One session id per WHOLE image transfer (per attempt). The glasses key their
2256
+ // reassembly buffer on MapSessionId, so every fragment of this image must reuse the
2257
+ // same session id with an incrementing MapFragmentIndex; the per-fragment ACK is
2258
+ // correlated by the (session, fragmentIndex) pair. A retry uses a fresh session so a
2259
+ // stale ACK from a prior attempt can't match.
2260
+ imageSessionCounter = (imageSessionCounter + 1) % 256
2261
+ let sessionId = imageSessionCounter
2262
+
2263
+ var fragmentIndex: Int32 = 0
2264
+ var offset = 0
2265
+ var transferOk = true
2266
+ // if attempt > 1 {
2267
+ // Bridge.log("G2: sendImageData(\(containerName)) - attempt \(attempt) starting")
2268
+ // }
2269
+ while offset < bmpData.count {
2270
+ let end = min(offset + fragmentSize, bmpData.count)
2271
+ let fragment = bmpData[offset..<end]
2272
+
2273
+ let msg = EvenHubProto.updateImageRawDataMessage(
2274
+ containerID: containerID,
2275
+ containerName: containerName,
2276
+ mapSessionId: Int32(sessionId),
2277
+ mapTotalSize: totalSize,
2278
+ compressMode: 0,
2279
+ mapFragmentIndex: fragmentIndex,
2280
+ mapFragmentPacketSize: Int32(fragment.count),
2281
+ mapRawData: Data(fragment)
2282
+ )
2283
+ // Send the fragment directly: the reconcile loop already serializes whole image
2284
+ // sends, and the per-fragment ACK gate below provides pacing — no transmit queue.
2285
+ sendEvenHubCommand(msg)
2286
+ // Bridge.log("G2: img_sen: session=\(sessionId) fragment=\(fragmentIndex)")
2287
+
2288
+ // Gate on THIS fragment's ACK before sending the next (the ACK provides pacing).
2289
+ // Timeout/img_failed → abandon the attempt and retry the whole image.
2290
+ let ok = await awaitImageAck(sessionId: sessionId, fragmentIndex: fragmentIndex)
2291
+ if !ok {
2292
+ Bridge.log(
2293
+ "G2: img_sen: session=\(sessionId) fragment=\(fragmentIndex) failed"
2294
+ )
2295
+ transferOk = false
2296
+ break
2297
+ }
2298
+
2299
+ fragmentIndex += 1
2300
+ offset = end
2301
+ }
2302
+
2303
+ if transferOk {
2304
+ // Bridge.log("G2: img_sen: container=\(containerName) - success=true")
2305
+ return
2306
+ }
2307
+ }
2308
+
2309
+ Bridge.log("G2: img_sen: container=\(containerName) - failed after \(IMG_MAX_ATTEMPTS) attempts")
2310
+ }
2311
+
2312
+ /// Suspend until correlateImageAck() resolves the ACK for this `(sessionId, fragmentIndex)`, or
2313
+ /// `IMG_ACK_TIMEOUT_NS` elapses (whichever comes first). Returns whether the fragment was acked
2314
+ /// successfully; a timeout returns false. All fragments of one image share a session id, so the
2315
+ /// fragment index distinguishes their ACKs. The continuation lives in [ImgAckBox], whose lock
2316
+ /// makes the ACK-resolve and the timeout mutually exclusive — exactly one resumes it.
2317
+ private func awaitImageAck(sessionId: Int, fragmentIndex: Int32) async -> Bool {
2318
+ let timeoutNs = IMG_ACK_TIMEOUT_NS
2319
+ let box = imgAckBox
2320
+ return await withCheckedContinuation { (cont: CheckedContinuation<Bool, Never>) in
2321
+ box.arm(session: sessionId, fragment: fragmentIndex, cont: cont)
2322
+
2323
+ // Detached (not @MainActor): the timeout must fire on its own schedule regardless of how
2324
+ // busy the main actor is. The box's lock makes racing with resolve() safe.
2325
+ Task.detached {
2326
+ try? await Task.sleep(nanoseconds: timeoutNs)
2327
+ box.timeout(session: sessionId, fragment: fragmentIndex)
2328
+ }
2067
2329
  }
2068
2330
  }
2069
2331
 
2332
+ /// Resume the pending image-ACK continuation for `(session, fragmentIndex)` (if it matches the
2333
+ /// in-flight one). Idempotent via [ImgAckBox]: a duplicate L/R ACK or the timeout can't resume
2334
+ /// the same continuation twice. Safe to call from any thread (the BLE callback queue).
2335
+ private nonisolated func completeImageAck(session: Int, fragmentIndex: Int32, success: Bool) {
2336
+ imgAckBox.resolve(session: session, fragment: fragmentIndex, success: success)
2337
+ }
2338
+
2070
2339
  /// Display a bitmap inside a positioned image container.
2071
2340
  ///
2072
2341
  /// The page keeps a live list of up to 4 image containers keyed by exact rect:
@@ -2080,6 +2349,9 @@ class G2: NSObject, SGCManager {
2080
2349
  base64ImageData: String, x: Int32? = nil, y: Int32? = nil, width: Int32? = nil,
2081
2350
  height: Int32? = nil
2082
2351
  ) async -> Bool {
2352
+ // Pure state mutation: update the target container's bytes and mark it dirty. The reconcile
2353
+ // loop (`displayReconcileTask`) is the sole sender, so two displayBitmap calls can never
2354
+ // overlap a `sendImageData` and clobber the single-slot imgAckBox — no lock needed.
2083
2355
  let rx = x ?? G2.defaultImgContainer.x
2084
2356
  let ry = y ?? G2.defaultImgContainer.y
2085
2357
  let rw = width ?? G2.defaultImgContainer.width
@@ -2105,33 +2377,117 @@ class G2: NSObject, SGCManager {
2105
2377
  }
2106
2378
 
2107
2379
  // Reuse an existing container if the rect matches exactly; otherwise add a new one.
2108
- var container: ImgContainer
2109
2380
  if let i = imageContainers.firstIndex(where: {
2110
2381
  $0.matches(x: rx, y: ry, width: rw, height: rh)
2111
2382
  }) {
2112
2383
  imageContainers[i].bmpData = bmpData
2113
- container = imageContainers[i]
2384
+ imageContainers[i].dirty = true
2385
+ signalDisplayDirty()
2386
+ let container = imageContainers[i]
2114
2387
  Bridge.log(
2115
2388
  "G2: displayBitmap() - reusing container \(container.id) for rect \(rx),\(ry) \(rw)x\(rh)"
2116
2389
  )
2390
+ // A brand-new page needs its structure built before the loop can push pixels; the dirty
2391
+ // flag stays set so the reconcile loop sends the image once the page exists.
2117
2392
  if !pageCreated {
2118
2393
  await rebuildPage()
2119
- return true
2120
2394
  }
2121
- await sendImageData(
2122
- containerID: container.id, containerName: container.name, bmpData: container.bmpData
2123
- )
2124
- return true
2125
2395
  } else {
2126
- container = addImageContainer(x: rx, y: ry, width: rw, height: rh, bmpData: bmpData)
2396
+ let container = addImageContainer(x: rx, y: ry, width: rw, height: rh, bmpData: bmpData)
2397
+ if let j = imageContainers.firstIndex(where: { $0.id == container.id }) {
2398
+ imageContainers[j].dirty = true
2399
+ }
2400
+ signalDisplayDirty()
2127
2401
  Bridge.log(
2128
2402
  "G2: displayBitmap() - added container \(container.id) for rect \(rx),\(ry) \(rw)x\(rh), rebuilding page"
2129
2403
  )
2404
+ // New container changes page structure: rebuild it, then the loop sends the pixels.
2130
2405
  await rebuildPage()
2131
2406
  }
2132
2407
  return true
2133
2408
  }
2134
2409
 
2410
+ /// Push pending display state to the glasses: dirty text containers first (one updateText each,
2411
+ /// with a redundant resend per `pendingSends`), then dirty image containers one at a time. The
2412
+ /// loop awaits this, and this awaits each `sendImageData` in turn, so image sends never overlap
2413
+ /// and `imgAckBox` is never clobbered. A failed image send leaves the container dirty for the
2414
+ /// next cycle; if its bytes changed mid-send, the flag stays set so the newer image is sent next.
2415
+ private func reconcileDisplay() async {
2416
+ // Page is dead but content is waiting (e.g. captions kept arriving while iOS had us
2417
+ // suspended and the firmware tore the session down). Rebuild the page ONCE here — the
2418
+ // reconcile loop is coalesced (1-deep signal buffer), so a burst of buffered sendText
2419
+ // calls collapses into a single rebuild instead of one shutdown/rebuild per caption.
2420
+ // rebuildState() recreates the page, re-pushes the current text/image, and re-arms the
2421
+ // mic iff intent says so. Skip while the native dashboard owns the screen.
2422
+ if !pageCreated {
2423
+ let useNativeDashboard =
2424
+ DeviceStore.shared.get("bluetooth", "use_native_dashboard") as? Bool ?? false
2425
+ // Only resurrect a dead page for non-blank content — don't rebuild just to render a
2426
+ // clearDisplay's blank, or a clear burst churns the page back up pointlessly.
2427
+ let hasPendingText = textContainers.contains {
2428
+ $0.pendingSends > 0 && $0.content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false
2429
+ }
2430
+ let hasPendingImage = imageContainers.contains { $0.dirty && !$0.bmpData.isEmpty }
2431
+ if (hasPendingText || hasPendingImage) && !(useNativeDashboard && dashboardShowing > 0) {
2432
+ Bridge.log("G2: reconcileDisplay() - page down with pending content, rebuilding once")
2433
+ await rebuildState()
2434
+ }
2435
+ return
2436
+ }
2437
+
2438
+ // Text: synchronous, no ACK. Send one update per container with pending sends and decrement.
2439
+ for i in textContainers.indices where textContainers[i].pendingSends > 0 {
2440
+ let container = textContainers[i]
2441
+ let msg = EvenHubProto.updateTextMessage(
2442
+ containerID: container.id,
2443
+ contentOffset: 0,
2444
+ contentLength: Int32(container.content.utf8.count),
2445
+ content: container.content
2446
+ )
2447
+ sendEvenHubCommand(msg)
2448
+ textContainers[i].pendingSends -= 1
2449
+ }
2450
+
2451
+ // Images: ACK-gated, exactly one in flight. Cap iterations defensively so a container that
2452
+ // keeps being re-dirtied mid-send can't spin this pass forever (next tick picks it up).
2453
+ var guardCount = 0
2454
+ while pageCreated, guardCount < imageContainerIDPool.count,
2455
+ let i = imageContainers.firstIndex(where: { $0.dirty })
2456
+ {
2457
+ guardCount += 1
2458
+ let container = imageContainers[i]
2459
+ let sentBytes = container.bmpData
2460
+ // Empty + dirty means "just cleared": the firmware still shows the old image, so push an
2461
+ // all-black frame sized to the container to overwrite it on-glass (the page stays up — no
2462
+ // teardown, no mic churn). A container only reaches here when dirty, and clearDisplay is
2463
+ // the sole source of an empty-but-dirty container, so this fires exactly on a clear.
2464
+ if sentBytes.isEmpty {
2465
+ if let blank = blankBmp(width: Int(container.width), height: Int(container.height)) {
2466
+ await sendImageData(
2467
+ containerID: container.id, containerName: container.name, bmpData: blank
2468
+ )
2469
+ }
2470
+ // Only settle the flag if it's still empty — a displayBitmap during the await would
2471
+ // have set new bytes, so leave it dirty for the next pass to send the real image.
2472
+ if let j = imageContainers.firstIndex(where: { $0.id == container.id }),
2473
+ imageContainers[j].bmpData.isEmpty
2474
+ {
2475
+ imageContainers[j].dirty = false
2476
+ }
2477
+ continue
2478
+ }
2479
+ await sendImageData(
2480
+ containerID: container.id, containerName: container.name, bmpData: sentBytes
2481
+ )
2482
+ // Re-find by id: the array may have shifted (eviction) during the await.
2483
+ if let j = imageContainers.firstIndex(where: { $0.id == container.id }),
2484
+ imageContainers[j].bmpData == sentBytes
2485
+ {
2486
+ imageContainers[j].dirty = false
2487
+ }
2488
+ }
2489
+ }
2490
+
2135
2491
  /// Add a new image container for `rect`, evicting the oldest when the list is full (max 4).
2136
2492
  /// Returns the newly tracked container (with an assigned ID from the pool).
2137
2493
  private func addImageContainer(x: Int32, y: Int32, width: Int32, height: Int32, bmpData: Data)
@@ -2176,44 +2532,69 @@ class G2: NSObject, SGCManager {
2176
2532
  let msg = EvenHubProto.shutdownMessage()
2177
2533
  sendEvenHubCommand(msg)
2178
2534
  pageCreated = false
2179
- await rebuildState()
2535
+ try? await Task.sleep(nanoseconds: 300_000_000)// 300ms to settle
2536
+ // we will automatically rebuild state when we detect the glasses shutdown:
2537
+ // await rebuildState()
2180
2538
  }
2181
2539
 
2182
- // re-creates the containers and sends all images and text again to the glasses:
2540
+ // re-creates the containers and re-sends all images to the glasses:
2183
2541
  private func rebuildState() async {
2184
2542
  Bridge.log("G2: rebuildState()")
2185
- // recreate the containers:
2543
+ // recreate the containers (sets pageCreated = true; embeds text content directly):
2186
2544
  createPageWithContainers()
2187
2545
 
2188
2546
  try? await Task.sleep(nanoseconds: 300_000_000) // 300ms to settle
2189
- // send any image containers we have
2190
- // go through each container and send the data:
2191
- for container in imageContainers {
2547
+ // Mark every image container dirty and let the reconcile loop re-send them, one at a time.
2548
+ // Doing the sends here directly is what used to race a concurrent displayBitmap and clobber
2549
+ // imgAckBox; routing through the dirty flag keeps a single sender (see displayReconcileTask).
2550
+ // Text needs no resend here: createPageWithContainers already embeds each container's content.
2551
+ for i in imageContainers.indices where !imageContainers[i].bmpData.isEmpty {
2192
2552
  Bridge.log(
2193
- "G2: rebuildState() - sending image data to container \(container.id), \(container.bmpData.count) bytes"
2194
- )
2195
- await sendImageData(
2196
- containerID: container.id, containerName: container.name, bmpData: container.bmpData
2553
+ "G2: rebuildState() - marking container \(imageContainers[i].id) dirty (\(imageContainers[i].bmpData.count) bytes)"
2197
2554
  )
2198
- try? await Task.sleep(nanoseconds: 300_000_000) // 300ms between containers
2555
+ imageContainers[i].dirty = true
2199
2556
  }
2557
+ signalDisplayDirty()
2200
2558
 
2201
- // go through each text container and send the data:
2202
- // disabled because text containers are initialized with their content:
2203
- // for container in textContainers {
2204
- // let msg = EvenHubProto.updateTextMessage(
2205
- // containerID: container.id,
2206
- // contentOffset: 0,
2207
- // contentLength: Int32(container.content.utf8.count),
2208
- // content: container.content
2209
- // )
2210
- // sendEvenHubCommand(msg)
2211
- // }
2212
-
2213
2559
  try? await Task.sleep(nanoseconds: 300_000_000) // 300ms to settle
2214
2560
  restartMicIfAlreadyEnabled()
2215
2561
  }
2216
2562
 
2563
+ /// Single coalesced recovery: rebuild the page + re-arm the mic from intent, but never
2564
+ /// stack rebuilds. The firmware spams systemExit/dashboard-close ~1×/sec; without this
2565
+ /// guard each one triggered a rebuild that was torn down again → rebuild→exit→rebuild
2566
+ /// storm. At most one rebuild in flight, and at most one per RECOVERY_DEBOUNCE_MS.
2567
+ private func recoverPageAndMic(reason: String) {
2568
+ let now = Int64(Date().timeIntervalSince1970 * 1000)
2569
+ // If the page is already alive and the mic matches intent, there's nothing to recover —
2570
+ // this is a spurious/phantom firmware event (it spams close/exit even when our page is
2571
+ // healthy). Rebuilding here is what created the churn, so skip it.
2572
+ let micIntent = DeviceStore.shared.get("glasses", "micEnabled") as? Bool ?? false
2573
+ if pageCreated && evenHubMicActive == micIntent {
2574
+ // Bridge.log("G2: recover(\(reason)) skipped — page alive, mic matches intent")
2575
+ return
2576
+ }
2577
+ if recoveryInFlight {
2578
+ // Bridge.log("G2: recover(\(reason)) skipped — already in flight")
2579
+ return
2580
+ }
2581
+ if now - lastRecoveryRebuildMs < RECOVERY_DEBOUNCE_MS {
2582
+ // Bridge.log("G2: recover(\(reason)) skipped — debounced")
2583
+ return
2584
+ }
2585
+ recoveryInFlight = true
2586
+ lastRecoveryRebuildMs = now
2587
+ Bridge.log("G2: recover(\(reason)) — rebuilding EvenHub page")
2588
+ Task { [weak self] in
2589
+ guard let self = self else { return }
2590
+ await self.rebuildState()
2591
+ // Reconcile against DeviceManager's authoritative current view so the glasses
2592
+ // match the phone, not just the last-cached G2 containers.
2593
+ DeviceManager.shared.sendCurrentState()
2594
+ self.recoveryInFlight = false
2595
+ }
2596
+ }
2597
+
2217
2598
  /// Upscale BMP pixel data by 2x (200x100 → 400x200) using nearest-neighbor
2218
2599
  private func upscaleBmp2x(_ bmpData: Data, srcWidth: Int, srcHeight: Int) -> Data? {
2219
2600
  // Parse the BMP to extract pixel data, then rebuild at 2x
@@ -2363,6 +2744,15 @@ class G2: NSObject, SGCManager {
2363
2744
  return bmp
2364
2745
  }
2365
2746
 
2747
+ /// Build an all-black BMP sized to a container. Sent to overwrite (and thus visually clear) an
2748
+ /// image container without tearing the page down — on the green monochrome display, pixel 0 is
2749
+ /// unlit, so an all-zero frame reads as blank. Used by the reconcile loop to clear a bitmap.
2750
+ private func blankBmp(width: Int, height: Int) -> Data? {
2751
+ guard width > 0, height > 0 else { return nil }
2752
+ let zeros = Data(count: width * height) // all-zero 8-bit grayscale = black
2753
+ return build4BitBmp(grayscalePixels: zeros, width: width, height: height)
2754
+ }
2755
+
2366
2756
  /// Build a 4-bit indexed BMP file from 8-bit grayscale pixel data.
2367
2757
  /// BMP rows are stored bottom-up. Each row is padded to a 4-byte boundary.
2368
2758
  private func build4BitBmp(grayscalePixels: Data, width: Int, height: Int) -> Data? {
@@ -2442,10 +2832,14 @@ class G2: NSObject, SGCManager {
2442
2832
  /// The glasses fall back to the dashboard automatically when no page is up.
2443
2833
  func showDashboard() {
2444
2834
  Bridge.log("G2: showDashboard()")
2445
- dashboardShowing += 2
2835
+ // Dashboard is open: a 0/1 flag (the old +=2/-=1 depth dance drifted >0 and wedged the
2836
+ // mic). dashboardOpening latches so the open-confirm 08011A00 doesn't trigger recovery.
2837
+ dashboardShowing = 1
2838
+ dashboardOpening = true
2446
2839
  let msg = EvenHubProto.shutdownMessage()
2447
2840
  sendEvenHubCommand(msg)
2448
2841
  pageCreated = false
2842
+ evenHubMicActive = false // dashboard takes EvenHub focus; firmware kills the mic
2449
2843
  currentBitmapBase64 = ""
2450
2844
  DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak self] in
2451
2845
  guard let self = self else { return }
@@ -2662,32 +3056,6 @@ class G2: NSObject, SGCManager {
2662
3056
  pageCreated = true
2663
3057
  }
2664
3058
 
2665
- private func queueEvenHubCommand(_ payload: Data) {
2666
- evenHubQueueLock.lock()
2667
- pendingTextMsg = payload
2668
- evenHubQueueLock.unlock()
2669
- }
2670
-
2671
- private func drainEvenHubQueue() {
2672
- evenHubQueueLock.lock()
2673
- let msg = pendingTextMsg
2674
- pendingTextMsg = nil
2675
- let toSend: Data?
2676
- if let msg = msg {
2677
- lastEvenHubMsg = msg
2678
- lastEvenHubResendsRemaining = EVEN_HUB_RESEND_COUNT
2679
- toSend = msg
2680
- } else if lastEvenHubResendsRemaining > 0, let last = lastEvenHubMsg {
2681
- lastEvenHubResendsRemaining -= 1
2682
- toSend = last
2683
- } else {
2684
- toSend = nil
2685
- }
2686
- evenHubQueueLock.unlock()
2687
- guard let toSend = toSend else { return }
2688
- sendEvenHubCommand(toSend)
2689
- }
2690
-
2691
3059
  private func restartMicIfAlreadyEnabled() {
2692
3060
  let currentEnabled = DeviceStore.shared.get("glasses", "micEnabled") as? Bool ?? false
2693
3061
  if currentEnabled {
@@ -2696,8 +3064,10 @@ class G2: NSObject, SGCManager {
2696
3064
  }
2697
3065
 
2698
3066
  func restartMic() {
2699
- // if already enabled, set to disabled, then send enabled after 500ms:
3067
+ // Intent is "mic on". The mic only exists inside a live EvenHub page, so we
3068
+ // toggle it off then back on (the firmware needs the off→on edge to re-arm).
2700
3069
  DeviceStore.shared.apply("glasses", "micEnabled", true)
3070
+ evenHubMicActive = false
2701
3071
  let msg = EvenHubProto.audioControlMessage(enable: false)
2702
3072
  sendEvenHubCommand(msg)
2703
3073
  DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak self] in
@@ -2705,14 +3075,24 @@ class G2: NSObject, SGCManager {
2705
3075
  let useNativeDashboard =
2706
3076
  DeviceStore.shared.get("bluetooth", "use_native_dashboard") as? Bool ?? false
2707
3077
  // Bridge.log("G2: setMicEnabled - useNativeDashboard=\(useNativeDashboard), dashboardShowing=\(dashboardShowing)")
3078
+ // Dashboard owns the screen + session right now — don't arm the mic into a
3079
+ // page the dashboard has taken over; recovery re-arms on dashboard close.
2708
3080
  if useNativeDashboard && dashboardShowing > 0 {
2709
3081
  return
2710
3082
  }
2711
- if !pageCreated {
2712
- DeviceManager.shared.sendCurrentState() // should re-create the page if needed
3083
+ // Never send audioControl(enable:true) without a live page — no page means no
3084
+ // mic. Rebuild first, which itself re-arms the mic at the end (intent is on),
3085
+ // so we're done.
3086
+ if !self.pageCreated {
3087
+ Task { [weak self] in
3088
+ await self?.rebuildState()
3089
+ DeviceManager.shared.sendCurrentState()
3090
+ }
3091
+ return
2713
3092
  }
2714
3093
  let msg = EvenHubProto.audioControlMessage(enable: true)
2715
3094
  self.sendEvenHubCommand(msg)
3095
+ self.evenHubMicActive = true
2716
3096
  }
2717
3097
  }
2718
3098
 
@@ -2720,8 +3100,12 @@ class G2: NSObject, SGCManager {
2720
3100
 
2721
3101
  func setMicEnabled(_ enabled: Bool) {
2722
3102
  Bridge.log("G2: setMicEnabled(\(enabled))")
3103
+ if enabled && !pageCreated {
3104
+ restartMic()
3105
+ return
3106
+ }
2723
3107
  let currentEnabled = DeviceStore.shared.get("glasses", "micEnabled") as? Bool ?? false
2724
- if currentEnabled && enabled {
3108
+ if enabled && currentEnabled {
2725
3109
  restartMic()
2726
3110
  return
2727
3111
  }
@@ -2729,6 +3113,7 @@ class G2: NSObject, SGCManager {
2729
3113
  DeviceStore.shared.apply("glasses", "micEnabled", enabled)
2730
3114
  let msg = EvenHubProto.audioControlMessage(enable: enabled)
2731
3115
  sendEvenHubCommand(msg)
3116
+ evenHubMicActive = enabled
2732
3117
  }
2733
3118
 
2734
3119
  func sortMicRanking(list: [String]) -> [String] {
@@ -2790,6 +3175,8 @@ class G2: NSObject, SGCManager {
2790
3175
  centralManager?.cancelPeripheralConnection(peripheral)
2791
3176
  }
2792
3177
 
3178
+ leftWriteQueue.removeAll()
3179
+ rightWriteQueue.removeAll()
2793
3180
  leftInitialized = false
2794
3181
  rightInitialized = false
2795
3182
  authStarted = false
@@ -2798,6 +3185,7 @@ class G2: NSObject, SGCManager {
2798
3185
  startupPageCreated = false
2799
3186
  pageCreated = false
2800
3187
  dashboardShowing = 0
3188
+ dashboardOpening = false
2801
3189
  heartbeatCounter = 0
2802
3190
  DeviceStore.shared.apply("glasses", "connected", false)
2803
3191
  DeviceStore.shared.apply("glasses", "fullyBooted", false)
@@ -2963,6 +3351,7 @@ class G2: NSObject, SGCManager {
2963
3351
  // } else {
2964
3352
  // stopCompass()
2965
3353
  // }
3354
+ Task { await runAuthSequence() }
2966
3355
  }
2967
3356
 
2968
3357
  /// Start a navigation session so the glasses stream compass heading via
@@ -3155,6 +3544,7 @@ class G2: NSObject, SGCManager {
3155
3544
  func sendButtonPhotoSettings() {}
3156
3545
  func sendButtonVideoRecordingSettings() {}
3157
3546
  func sendButtonMaxRecordingTime() {}
3547
+ func sendButtonCameraLedSetting() {}
3158
3548
 
3159
3549
  func sendCameraFovSetting() {}
3160
3550
 
@@ -3442,6 +3832,15 @@ class G2: NSObject, SGCManager {
3442
3832
  var reader = ProtobufReader(payload)
3443
3833
  let fields = reader.parseFields()
3444
3834
 
3835
+
3836
+ let payloadStr = "\(payload.map { String(format: "%02X", $0) }.joined())"
3837
+ if payloadStr.contains("080C7A02100C") {
3838
+ // heartbeat response
3839
+ return
3840
+ }
3841
+
3842
+ // Bridge.log("G2: hub_res: payload=\(payload.map { String(format: "%02X", $0) }.joined())")
3843
+
3445
3844
  guard let cmdValue = fields[1] as? Int32 else {
3446
3845
  Bridge.log(
3447
3846
  "G2: EvenHub response - no cmd field, \(payload.count) bytes: \(payload.map { String(format: "%02X", $0) }.joined())"
@@ -3449,7 +3848,6 @@ class G2: NSObject, SGCManager {
3449
3848
  return
3450
3849
  }
3451
3850
 
3452
- // Bridge.log("G2: EvenHub incoming cmd=\(cmdValue), fields=\(Array(fields.keys).sorted())")
3453
3851
 
3454
3852
  if cmdValue == EvenHubResponseCmd.osNotifyEventToApp.rawValue {
3455
3853
  // Touch/gesture event from glasses
@@ -3492,6 +3890,10 @@ class G2: NSObject, SGCManager {
3492
3890
  }
3493
3891
  } else {
3494
3892
 
3893
+ // NOTE: the per-fragment image ACK is correlated inline on the BLE callback queue in
3894
+ // correlateImageAck() (called from didUpdateValueFor before this is dispatched to the
3895
+ // main actor) so it is never delayed behind a saturated main actor. Nothing to do here.
3896
+
3495
3897
  // response codes:
3496
3898
  let timestamp = Int64(Date().timeIntervalSince1970 * 1000)
3497
3899
  if lastEvenHubResponseTimestamp != nil
@@ -3520,16 +3922,17 @@ class G2: NSObject, SGCManager {
3520
3922
  "G2: WARN: Glasses shutdown our EvenHub page — resetting page state"
3521
3923
  )
3522
3924
  pageCreated = false
3925
+ evenHubMicActive = false // mic dies with the page
3523
3926
  }
3524
3927
  }
3525
- if let errorCode = resFields[8] as? Int32 {
3526
- // ImgResCmd has ErrorCode in field 8
3527
- if errorCode == 4 {
3528
- Bridge.log("G2: img_success")
3529
- } else {
3530
- Bridge.log("G2: EvenHub ImgRes errorCode=\(errorCode)")
3531
- }
3532
- }
3928
+ // if let errorCode = resFields[8] as? Int32 {
3929
+ // // ImgResCmd has ErrorCode in field 8
3930
+ // if errorCode == 4 {
3931
+ // Bridge.log("G2: img_success")
3932
+ // } else {
3933
+ // Bridge.log("G2: EvenHub ImgRes errorCode=\(errorCode)")
3934
+ // }
3935
+ // }
3533
3936
  }
3534
3937
  }
3535
3938
 
@@ -3537,6 +3940,7 @@ class G2: NSObject, SGCManager {
3537
3940
  if cmdValue == 9 || cmdValue == 10 {
3538
3941
  Bridge.log("G2: ERROR: Glasses shutdown our EvenHub page — resetting page state")
3539
3942
  pageCreated = false
3943
+ evenHubMicActive = false // mic dies with the page
3540
3944
  }
3541
3945
  }
3542
3946
  }
@@ -3696,23 +4100,15 @@ class G2: NSObject, SGCManager {
3696
4100
  // Bridge.log("G2: Click detected")
3697
4101
  // }
3698
4102
 
3699
- // System exit: glasses killed our EvenHub page (user opened menu or another app)
3700
- // Reset page state and re-create the page to reclaim EvenHub focus
4103
+ // System exit: the firmware killed our page (and the mic). ONLY mark state dead;
4104
+ // do NOT rebuild here. systemExit is fired alongside the dashboard-close (08011A00)
4105
+ // event for the same transition — if both rebuilt, the fresh page gets torn down
4106
+ // again → rebuild→exit→rebuild loop. Recovery is owned by one place: the
4107
+ // dashboard-close handler (and the reconcile page-down path). Don't touch
4108
+ // micEnabled (user intent) — recovery reads it to re-arm; clobbering it strands the mic.
3701
4109
  if eventType == .systemExit || eventType == .abnormalExit {
3702
- // Bridge.log("G2: System exit detected")
3703
4110
  pageCreated = false
3704
- // Firmware kills the mic on system exit; re-arm it if it should be on
3705
- DeviceStore.shared.apply("glasses", "micEnabled", false)
3706
- DeviceManager.shared.updateMicState()
3707
- // Force re-create the page to reclaim EvenHub focus
3708
- // Task {
3709
- // try? await Task.sleep(nanoseconds: 1_000_000_000) // 1000ms for glasses to finish transition
3710
- // if !savedBitmap.isEmpty {
3711
- // await self.displayBitmap(base64ImageData: savedBitmap)
3712
- // } else {
3713
- // self.sendTextWall(savedText.isEmpty ? " " : savedText)
3714
- // }
3715
- // }
4111
+ evenHubMicActive = false // firmware killed the mic with the page
3716
4112
  }
3717
4113
  return
3718
4114
  }
@@ -4024,46 +4420,21 @@ class G2: NSObject, SGCManager {
4024
4420
  }
4025
4421
  lastGestureCtrlTimestamp = timestamp
4026
4422
 
4027
- // if we got 08011A00 that means we closed the dashboard, which means the mic is probably dead,
4028
- // so we need to revive it:
4423
+ // 08011A00 is the dashboard open/close toggle. It fires on BOTH transitions, so we use
4424
+ // the dashboardOpening latch (set by showDashboard) to tell them apart:
4425
+ // • First event after showDashboard → the OPEN confirm. Consume it, keep the dashboard
4426
+ // up, and do NOT recover (recovering would rebuild our page and snatch the screen back
4427
+ // — that's the "double-tap makes captions flicker but never opens the dashboard" bug).
4428
+ // • Next event → the real CLOSE. Reset state and recover our page + mic.
4029
4429
  if data == Data([0x08, 0x01, 0x1A, 0x00]) {
4030
- Bridge.log("G2: dashboard closed / shutdown - dashboardShowing=\(dashboardShowing)")
4031
- let useNativeDashboard =
4032
- DeviceStore.shared.get("bluetooth", "use_native_dashboard") as? Bool ?? false
4033
- if !useNativeDashboard {
4034
- dashboardShowing = 0
4035
- // rebuild state:
4036
- Task {
4037
- await rebuildState()
4038
- // set the mic back on if it should be on
4039
- let micEnabled =
4040
- DeviceStore.shared.get("glasses", "micEnabled") as? Bool ?? false
4041
- if micEnabled {
4042
- restartMic()
4043
- }
4044
- }
4430
+ Bridge.log("G2: dashboard toggle - dashboardShowing=\(dashboardShowing) opening=\(dashboardOpening)")
4431
+ if dashboardOpening {
4432
+ dashboardOpening = false // open confirmed; dashboard now owns the screen
4045
4433
  return
4046
- } else {
4047
- // if we aren't trying to show the dashboard
4048
- // then we need to turn the mic back on and display the mentra main page:
4049
- if dashboardShowing <= 1 {
4050
- dashboardShowing = 0
4051
- // rebuild state:
4052
- await rebuildState()
4053
- // set the mic back on if it should be on
4054
- let micEnabled =
4055
- DeviceStore.shared.get("glasses", "micEnabled") as? Bool ?? false
4056
- if micEnabled {
4057
- restartMic()
4058
- }
4059
- return
4060
- }
4061
- // do nothing this time since we just closed the dashboard
4062
- dashboardShowing -= 1
4063
- if dashboardShowing < 0 {
4064
- dashboardShowing = 0
4065
- }
4066
4434
  }
4435
+ dashboardShowing = 0
4436
+ recoverPageAndMic(reason: "dashboard-close")
4437
+ return
4067
4438
  }
4068
4439
 
4069
4440
  // if we got 08011097012200 that means we selected a menu item:
@@ -4258,6 +4629,8 @@ extension G2: CBCentralManagerDelegate {
4258
4629
  // Clear both sides to force re-discovery (like G1)
4259
4630
  self.leftPeripheral = nil
4260
4631
  self.rightPeripheral = nil
4632
+ self.leftWriteQueue.removeAll()
4633
+ self.rightWriteQueue.removeAll()
4261
4634
  self.leftInitialized = false
4262
4635
  self.rightInitialized = false
4263
4636
  self.leftWriteChar = nil
@@ -4271,6 +4644,7 @@ extension G2: CBCentralManagerDelegate {
4271
4644
  self.startupPageCreated = false
4272
4645
  self.pageCreated = false
4273
4646
  self.dashboardShowing = 0
4647
+ self.dashboardOpening = false
4274
4648
  DeviceStore.shared.apply("glasses", "connected", false)
4275
4649
  DeviceStore.shared.apply("glasses", "fullyBooted", false)
4276
4650
 
@@ -4390,6 +4764,14 @@ extension G2: CBPeripheralDelegate {
4390
4764
  ) {
4391
4765
  guard let data = characteristic.value, error == nil else { return }
4392
4766
 
4767
+ // Correlate an in-flight image-fragment ACK INLINE on the BLE callback queue, before hopping
4768
+ // to the main actor. This keeps the ACK that sendImageData() awaits from being starved by a
4769
+ // saturated main actor (packet burst / heartbeats / text-queue tick) — the cause of the
4770
+ // intermittent "glasses stopped responding to image sends". See [ImgAckBox].
4771
+ if characteristic.uuid == G2BLE.CHAR_NOTIFY {
4772
+ correlateImageAck(data)
4773
+ }
4774
+
4393
4775
  Task { @MainActor [weak self] in
4394
4776
  guard let self = self else { return }
4395
4777
 
@@ -4403,6 +4785,50 @@ extension G2: CBPeripheralDelegate {
4403
4785
  }
4404
4786
  }
4405
4787
 
4788
+ /// Read-only, SINGLE-PACKET parse of a raw notify frame that resolves an in-flight image ACK
4789
+ /// directly (off the main actor). Does NOT touch the stateful `receiveManager` (main-actor only):
4790
+ /// an ImgResCmd always fits in one BLE packet, so any multi-packet frame is left to the
4791
+ /// main-thread path. Idempotent via [ImgAckBox], so the residual main-thread correlation and the
4792
+ /// duplicate L/R ACK are both harmless.
4793
+ nonisolated func correlateImageAck(_ rawData: Data) {
4794
+ guard rawData.count >= 8 else { return }
4795
+ let b = { (i: Int) -> UInt8 in rawData[rawData.startIndex + i] }
4796
+ guard b(0) == G2BLE.HEADER_BYTE else { return }
4797
+
4798
+ let payloadLen = Int(b(3))
4799
+ let expectedLen = payloadLen + 8
4800
+ guard rawData.count >= expectedLen else { return }
4801
+
4802
+ let totalPackets = Int(b(4))
4803
+ let serialNum = Int(b(5))
4804
+ let serviceId = b(6)
4805
+ let resultCode = (Int(b(7)) >> 1) & 0x0F
4806
+ guard resultCode == 0 else { return }
4807
+ guard serviceId == ServiceID.evenHub.rawValue else { return }
4808
+ // Single complete packet only (ImgResCmd never spans packets).
4809
+ guard totalPackets == 1, serialNum == 1 else { return }
4810
+
4811
+ // Strip the 2-byte CRC trailer on the (last == only) packet.
4812
+ let payloadEnd = 8 + payloadLen - 2
4813
+ guard payloadEnd >= 8, payloadEnd <= rawData.count else { return }
4814
+ let payload = rawData.subdata(in: (rawData.startIndex + 8)..<(rawData.startIndex + payloadEnd))
4815
+
4816
+ var reader = ProtobufReader(payload)
4817
+ let fields = reader.parseFields()
4818
+ guard let resData = fields[6] as? Data else { return } // field 6 = ImgResCmd
4819
+ var resReader = ProtobufReader(resData)
4820
+ let resFields = resReader.parseFields()
4821
+ guard let errorCode = resFields[8] as? Int32,
4822
+ let ackSession = resFields[3] as? Int32
4823
+ else { return }
4824
+ let ackFragment = (resFields[6] as? Int32) ?? 0
4825
+ Bridge.log(
4826
+ "G2: img_res: session=\(ackSession) fragment=\(ackFragment) errorCode=\(errorCode) success=\(errorCode == 4)"
4827
+ )
4828
+ completeImageAck(
4829
+ session: Int(ackSession), fragmentIndex: ackFragment, success: errorCode == 4)
4830
+ }
4831
+
4406
4832
  nonisolated func peripheral(
4407
4833
  _: CBPeripheral, didWriteValueFor _: CBCharacteristic, error: Error?
4408
4834
  ) {
@@ -4412,4 +4838,18 @@ extension G2: CBPeripheralDelegate {
4412
4838
  }
4413
4839
  }
4414
4840
  }
4841
+
4842
+ /// CoreBluetooth's write-without-response buffer freed up. The paced drainer doesn't depend on
4843
+ /// this (it's suppressed in the background), but in the foreground it's a cheap nudge to make
4844
+ /// sure a drainer is running for that side.
4845
+ nonisolated func peripheralIsReady(toSendWriteWithoutResponse peripheral: CBPeripheral) {
4846
+ Task { @MainActor [weak self] in
4847
+ guard let self = self else { return }
4848
+ if peripheral === self.rightPeripheral {
4849
+ self.startDrain(right: true)
4850
+ } else if peripheral === self.leftPeripheral {
4851
+ self.startDrain(right: false)
4852
+ }
4853
+ }
4854
+ }
4415
4855
  }