@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.
- package/README.md +4 -6
- package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkModule.kt +22 -9
- package/android/src/main/java/com/mentra/bluetoothsdk/Bridge.kt +5 -20
- package/android/src/main/java/com/mentra/bluetoothsdk/DeviceManager.kt +358 -261
- package/android/src/main/java/com/mentra/bluetoothsdk/DeviceStore.kt +3 -3
- package/android/src/main/java/com/mentra/bluetoothsdk/MentraBluetoothSdk.kt +133 -52
- package/android/src/main/java/com/mentra/bluetoothsdk/OtaManifest.kt +12 -12
- package/android/src/main/java/com/mentra/bluetoothsdk/camera/CameraModels.kt +22 -6
- package/android/src/main/java/com/mentra/bluetoothsdk/events/BluetoothEvents.kt +1 -22
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/G1.kt +50 -3
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/G2.kt +1184 -795
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraLive.kt +422 -78
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraNex.kt +3 -4
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/Nimo.kt +2532 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/SGCManager.kt +15 -1
- package/android/src/main/java/com/mentra/bluetoothsdk/types/DeviceModels.kt +5 -3
- package/android/src/main/java/com/mentra/bluetoothsdk/utils/Constants.kt +2 -1
- package/android/src/test/java/com/mentra/bluetoothsdk/camera/PhotoRequestTest.kt +60 -1
- package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/NimoProtocolTest.kt +333 -0
- package/build/BluetoothSdk.types.d.ts +39 -14
- package/build/BluetoothSdk.types.d.ts.map +1 -1
- package/build/BluetoothSdk.types.js +1 -0
- package/build/BluetoothSdk.types.js.map +1 -1
- package/build/_internal.d.ts +4 -4
- package/build/_internal.js +4 -4
- package/build/_internal.js.map +1 -1
- package/build/_private/BluetoothSdkModule.d.ts +11 -3
- package/build/_private/BluetoothSdkModule.d.ts.map +1 -1
- package/build/_private/BluetoothSdkModule.js +5 -0
- package/build/_private/BluetoothSdkModule.js.map +1 -1
- package/build/_private/cameraRequestPayload.d.ts +4 -0
- package/build/_private/cameraRequestPayload.d.ts.map +1 -0
- package/build/_private/cameraRequestPayload.js +25 -0
- package/build/_private/cameraRequestPayload.js.map +1 -0
- package/build/_private/photoRequestPayload.d.ts.map +1 -1
- package/build/_private/photoRequestPayload.js +8 -2
- package/build/_private/photoRequestPayload.js.map +1 -1
- package/build/index.d.ts +1 -1
- package/build/index.d.ts.map +1 -1
- package/build/index.js +72 -54
- package/build/index.js.map +1 -1
- package/ios/BluetoothSdkModule.swift +40 -12
- package/ios/Source/Bridge.swift +4 -16
- package/ios/Source/DeviceManager.swift +72 -12
- package/ios/Source/MentraBluetoothSDK.swift +120 -50
- package/ios/Source/OtaManifest.swift +13 -13
- package/ios/Source/camera/CameraModels.swift +75 -9
- package/ios/Source/errors/{BluetoothError.swift → BluetoothSdkError.swift} +1 -1
- package/ios/Source/events/BluetoothEvents.swift +5 -27
- package/ios/Source/internal/BluetoothAvailability.swift +5 -5
- package/ios/Source/sgcs/G1.swift +51 -1
- package/ios/Source/sgcs/G2.swift +643 -203
- package/ios/Source/sgcs/MentraLive.swift +53 -39
- package/ios/Source/sgcs/Nimo.swift +1928 -0
- package/ios/Source/sgcs/SGCManager.swift +12 -5
- package/ios/Source/stt/STTTools.swift +1 -1
- package/ios/Source/types/DeviceModels.swift +5 -0
- package/ios/Source/utils/Constants.swift +2 -0
- package/package.json +6 -1
- package/src/BluetoothSdk.types.ts +42 -17
- package/src/_internal.ts +4 -4
- package/src/_private/BluetoothSdkModule.ts +18 -2
- package/src/_private/cameraRequestPayload.ts +29 -0
- package/src/_private/photoRequestPayload.ts +9 -2
- package/src/index.ts +76 -58
package/ios/Source/sgcs/G2.swift
CHANGED
|
@@ -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
|
-
|
|
1469
|
-
|
|
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
|
-
|
|
1568
|
-
|
|
1569
|
-
|
|
1570
|
-
|
|
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
|
|
1573
|
-
|
|
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
|
-
//
|
|
1863
|
-
|
|
1864
|
-
|
|
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
|
-
|
|
1869
|
-
|
|
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
|
-
|
|
1879
|
-
|
|
1880
|
-
|
|
1881
|
-
|
|
1882
|
-
|
|
1883
|
-
|
|
1884
|
-
|
|
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
|
-
//
|
|
1973
|
-
|
|
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
|
-
|
|
1981
|
-
|
|
1982
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1989
|
-
|
|
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
|
-
//
|
|
2017
|
-
//
|
|
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
|
-
|
|
2032
|
-
Task { await rebuildPage() }
|
|
2229
|
+
signalDisplayDirty()
|
|
2033
2230
|
}
|
|
2034
2231
|
|
|
2035
|
-
/// Send
|
|
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
|
-
|
|
2042
|
-
var offset = 0
|
|
2243
|
+
let fragmentCount = (bmpData.count + fragmentSize - 1) / fragmentSize
|
|
2043
2244
|
|
|
2044
|
-
|
|
2045
|
-
|
|
2046
|
-
|
|
2245
|
+
// skip if the image is empty:
|
|
2246
|
+
if bmpData.count == 0 {
|
|
2247
|
+
return
|
|
2248
|
+
}
|
|
2047
2249
|
|
|
2048
|
-
|
|
2049
|
-
|
|
2050
|
-
|
|
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
|
-
|
|
2065
|
-
|
|
2066
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
//
|
|
2190
|
-
//
|
|
2191
|
-
|
|
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() -
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
2712
|
-
|
|
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
|
|
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
|
-
|
|
3527
|
-
|
|
3528
|
-
|
|
3529
|
-
|
|
3530
|
-
|
|
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:
|
|
3700
|
-
//
|
|
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
|
-
//
|
|
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
|
-
//
|
|
4028
|
-
//
|
|
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
|
|
4031
|
-
|
|
4032
|
-
|
|
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
|
}
|