@mentra/bluetooth-sdk 0.1.17 → 0.1.19
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 +1 -1
- package/android/src/main/java/com/mentra/bluetoothsdk/Bridge.kt +52 -4
- package/android/src/main/java/com/mentra/bluetoothsdk/DeviceManager.kt +119 -16
- package/android/src/main/java/com/mentra/bluetoothsdk/DeviceStore.kt +0 -6
- package/android/src/main/java/com/mentra/bluetoothsdk/MentraBluetoothSdk.kt +22 -21
- package/android/src/main/java/com/mentra/bluetoothsdk/OtaManifest.kt +1 -1
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/G2.kt +488 -15
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraLive.kt +457 -31
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraLiveL2capChannel.kt +274 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraNex.kt +361 -67
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraosBle.java +7501 -3704
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/SGCManager.kt +134 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/status/DeviceStatus.kt +0 -6
- package/android/src/main/java/com/mentra/bluetoothsdk/utils/BleJsonCompact.java +493 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/utils/BlePhotoUploadService.java +4 -1
- package/android/src/main/java/com/mentra/bluetoothsdk/utils/BleWireProtocol.java +108 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/utils/IncidentLogBleUploadService.java +4 -3
- package/android/src/main/java/com/mentra/bluetoothsdk/utils/K900LengthCodec.java +115 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/utils/K900ProtocolUtils.java +103 -68
- package/android/src/main/java/com/mentra/bluetoothsdk/utils/MessageChunkReassembler.java +106 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/utils/MessageChunker.java +93 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/utils/NexSGCUtils.kt +111 -11
- package/android/src/test/java/com/mentra/bluetoothsdk/camera/PhotoRequestTest.kt +7 -9
- package/android/src/test/java/com/mentra/bluetoothsdk/utils/AvifExifStripperTest.java +17 -1
- package/android/src/test/java/com/mentra/bluetoothsdk/utils/BinaryMessageChunkerTest.java +84 -0
- package/android/src/test/java/com/mentra/bluetoothsdk/utils/BleJsonCompactTest.java +158 -0
- package/android/src/test/java/com/mentra/bluetoothsdk/utils/K900ProtocolUtilsEndiannessTest.java +172 -0
- package/android/src/test/java/com/mentra/bluetoothsdk/utils/K900ProtocolUtilsTest.java +24 -0
- package/build/BluetoothSdk.types.d.ts +12 -1
- package/build/BluetoothSdk.types.d.ts.map +1 -1
- package/build/BluetoothSdk.types.js.map +1 -1
- package/build/types/index.d.ts +3 -0
- package/build/types/index.d.ts.map +1 -0
- package/build/types/index.js +2 -0
- package/build/types/index.js.map +1 -0
- package/ios/LocalPhotoUploadServer.swift +78 -0
- package/ios/MentraPhotoReceiverModule.swift +10 -0
- package/ios/Source/BluetoothSdkDefaults.swift +1 -1
- package/ios/Source/Bridge.swift +63 -0
- package/ios/Source/DeviceManager.swift +134 -29
- package/ios/Source/DeviceStore.swift +0 -5
- package/ios/Source/MentraBluetoothSDK.swift +26 -23
- package/ios/Source/OtaManifest.swift +1 -1
- package/ios/Source/internal/BleTraceLogger.swift +192 -0
- package/ios/Source/sgcs/G2.swift +518 -11
- package/ios/Source/sgcs/MentraLive.swift +607 -39
- package/ios/Source/sgcs/MentraNex.swift +416 -32
- package/ios/Source/sgcs/SGCManager.swift +155 -0
- package/ios/Source/sgcs/mentraos_ble.pb.swift +726 -122
- package/ios/Source/status/DeviceStatus.swift +0 -8
- package/ios/Source/utils/BleJsonCompact.swift +395 -0
- package/ios/Source/utils/BleWireProtocol.swift +92 -0
- package/ios/Source/utils/MessageChunkReassembler.swift +82 -0
- package/ios/Source/utils/MessageChunker.swift +73 -0
- package/package.json +14 -7
- package/src/BluetoothSdk.types.ts +13 -1
- package/src/types/index.ts +7 -0
package/ios/Source/sgcs/G2.swift
CHANGED
|
@@ -1622,6 +1622,43 @@ class G2: NSObject, SGCManager {
|
|
|
1622
1622
|
/// Fixed pool of container IDs the page protocol expects.
|
|
1623
1623
|
private let imageContainerIDPool: [Int32] = [10, 11, 12, 13]
|
|
1624
1624
|
private let textContainerIDPool: [Int32] = [1, 2, 3, 4, 5, 6]
|
|
1625
|
+
|
|
1626
|
+
/// One firmware text line (hardware-calibrated 2026-07-03: 28px overflows,
|
|
1627
|
+
/// 40px clean). Text containers are silently grown to at least this.
|
|
1628
|
+
private static let minTextContainerHeight: Int32 = 40
|
|
1629
|
+
|
|
1630
|
+
/// Scene-verb registries (display.render() pipeline): element id → container
|
|
1631
|
+
/// id(s). Containers stay rect-keyed underneath — these pin element↔container
|
|
1632
|
+
/// so content updates go in place and moves recreate at the SAME container
|
|
1633
|
+
/// id. Images map to an ARRAY of containers: firmware refuses image
|
|
1634
|
+
/// transfers into containers beyond ~200x100, so bigger images tile across
|
|
1635
|
+
/// multiple containers (e.g. 400x100 → two side-by-side, 150x150 → two
|
|
1636
|
+
/// stacked). At most one app's ids are live at a time (DeviceManager sweeps
|
|
1637
|
+
/// the previous app's elements on an app switch).
|
|
1638
|
+
private var sceneTextByElement: [String: Int32] = [:]
|
|
1639
|
+
private var sceneImageByElement: [String: [Int32]] = [:]
|
|
1640
|
+
|
|
1641
|
+
/// Max image-container size the firmware accepts pixels for
|
|
1642
|
+
/// (hardware-verified 2026-07-03: 150x150 refused, 100x100 clean; 200x100
|
|
1643
|
+
/// is the firmware default container and the old docs' boundary).
|
|
1644
|
+
private static let maxImageTileW: Int32 = 200
|
|
1645
|
+
private static let maxImageTileH: Int32 = 100
|
|
1646
|
+
|
|
1647
|
+
/// Tile rects (relative to the element box) covering w×h with tiles the
|
|
1648
|
+
/// firmware will accept. Row-major.
|
|
1649
|
+
private static func imageTileRects(w: Int32, h: Int32) -> [(dx: Int32, dy: Int32, w: Int32, h: Int32)] {
|
|
1650
|
+
let cols = Int((w + maxImageTileW - 1) / maxImageTileW)
|
|
1651
|
+
let rows = Int((h + maxImageTileH - 1) / maxImageTileH)
|
|
1652
|
+
var rects: [(Int32, Int32, Int32, Int32)] = []
|
|
1653
|
+
for r in 0 ..< rows {
|
|
1654
|
+
for c in 0 ..< cols {
|
|
1655
|
+
let dx = Int32(c) * maxImageTileW
|
|
1656
|
+
let dy = Int32(r) * maxImageTileH
|
|
1657
|
+
rects.append((dx, dy, min(maxImageTileW, w - dx), min(maxImageTileH, h - dy)))
|
|
1658
|
+
}
|
|
1659
|
+
}
|
|
1660
|
+
return rects
|
|
1661
|
+
}
|
|
1625
1662
|
private static let defaultImgContainer = (
|
|
1626
1663
|
x: Int32(188), y: Int32(44), width: Int32(200), height: Int32(100)
|
|
1627
1664
|
)
|
|
@@ -2149,9 +2186,16 @@ class G2: NSObject, SGCManager {
|
|
|
2149
2186
|
}
|
|
2150
2187
|
|
|
2151
2188
|
let rx = x ?? G2.defaultTextContainer.x
|
|
2152
|
-
|
|
2189
|
+
// Legacy callers can hand us y beyond the canvas — clamp it first so
|
|
2190
|
+
// the height formula below can't go negative/below one fw line.
|
|
2191
|
+
let ry = min(max(y ?? G2.defaultTextContainer.y, 0), 288 - G2.minTextContainerHeight)
|
|
2153
2192
|
let rw = width ?? G2.defaultTextContainer.width
|
|
2154
|
-
|
|
2193
|
+
// Firmware guard (hardware-calibrated): a text container shorter than
|
|
2194
|
+
// one firmware line (~40px; 28px overflowed) makes the fw draw its
|
|
2195
|
+
// overflow-indicator tick. Silently grow to one line, clamped to the
|
|
2196
|
+
// canvas. Content-independent so rect keys stay stable across updates.
|
|
2197
|
+
let rhRequested = height ?? G2.defaultTextContainer.height
|
|
2198
|
+
let rh = min(max(rhRequested, G2.minTextContainerHeight), 288 - ry)
|
|
2155
2199
|
let borderWidth = borderWidth ?? G2.defaultTextContainer.borderWidth
|
|
2156
2200
|
let borderColor = borderColor ?? G2.defaultTextContainer.borderColor
|
|
2157
2201
|
let borderRadius = borderRadius ?? G2.defaultTextContainer.borderRadius
|
|
@@ -2199,7 +2243,7 @@ class G2: NSObject, SGCManager {
|
|
|
2199
2243
|
textContainers[j].pendingSends = 1 + EVEN_HUB_RESEND_COUNT
|
|
2200
2244
|
}
|
|
2201
2245
|
signalDisplayDirty()
|
|
2202
|
-
await
|
|
2246
|
+
await requestPageRebuild()
|
|
2203
2247
|
}
|
|
2204
2248
|
|
|
2205
2249
|
func sendDoubleTextWall(_ top: String, _ bottom: String) async {
|
|
@@ -2209,13 +2253,342 @@ class G2: NSObject, SGCManager {
|
|
|
2209
2253
|
await sendTextWall(combined)
|
|
2210
2254
|
}
|
|
2211
2255
|
|
|
2256
|
+
// MARK: - Scene verbs (display.render() pipeline)
|
|
2257
|
+
|
|
2258
|
+
/// Scene-frame rebuild batching. A frame with several new containers must
|
|
2259
|
+
/// NOT rebuild the page once per create: rebuildPage sends SHUTDOWN_PAGE,
|
|
2260
|
+
/// and 4-5 shutdown/recover cycles back-to-back are a firmware rebuild
|
|
2261
|
+
/// storm — the G2 punishes those by dropping the BLE link (same failure
|
|
2262
|
+
/// family as the mic-session incidents). While a frame is being applied,
|
|
2263
|
+
/// structural changes only mark this flag; applySceneFrame does ONE rebuild
|
|
2264
|
+
/// at the end.
|
|
2265
|
+
private var sceneBatchDepth = 0
|
|
2266
|
+
private var sceneStructuralPending = false
|
|
2267
|
+
|
|
2268
|
+
/// Rebuild now — or, inside an applySceneFrame batch, once at frame end.
|
|
2269
|
+
private func requestPageRebuild() async {
|
|
2270
|
+
if sceneBatchDepth > 0 {
|
|
2271
|
+
sceneStructuralPending = true
|
|
2272
|
+
return
|
|
2273
|
+
}
|
|
2274
|
+
await coalescedPageRebuild()
|
|
2275
|
+
}
|
|
2276
|
+
|
|
2277
|
+
/// Structural change: tear down + rebuild ONLY when the page is actually
|
|
2278
|
+
/// live. When the page is already down (mid-recovery, or a prior frame's
|
|
2279
|
+
/// rebuild in flight), sending another SHUTDOWN_PAGE restarts the firmware
|
|
2280
|
+
/// recovery cycle — frames arriving faster than recovery completes then
|
|
2281
|
+
/// keep the page down FOREVER (nothing renders, image fragments all fail).
|
|
2282
|
+
/// A down page just needs the dirty signal: the reconcile loop resurrects
|
|
2283
|
+
/// it once, with the full current container list, which already includes
|
|
2284
|
+
/// every structural change accumulated while it was down.
|
|
2285
|
+
private func coalescedPageRebuild() async {
|
|
2286
|
+
if pageCreated {
|
|
2287
|
+
await rebuildPage()
|
|
2288
|
+
} else {
|
|
2289
|
+
Bridge.log("G2: structural change while page down — deferring to reconcile rebuild (no extra shutdown)")
|
|
2290
|
+
signalDisplayDirty()
|
|
2291
|
+
}
|
|
2292
|
+
}
|
|
2293
|
+
|
|
2294
|
+
/// G2 override of the default paint-then-sweep: identical walk, but
|
|
2295
|
+
/// structural rebuilds are coalesced to a single shutdown/rebuild per frame.
|
|
2296
|
+
func applySceneFrame(_ frame: SceneFrame) async {
|
|
2297
|
+
if frame.replay {
|
|
2298
|
+
await onSceneReplay(frame.appId)
|
|
2299
|
+
}
|
|
2300
|
+
sceneBatchDepth += 1
|
|
2301
|
+
sceneStructuralPending = false
|
|
2302
|
+
// Type-changed ids (removed AND re-painted this frame) must be removed
|
|
2303
|
+
// BEFORE the paint — post-paint removal would delete the just-painted
|
|
2304
|
+
// replacement (registries key by id).
|
|
2305
|
+
let paintedIds = Set(frame.elements.map { $0.id })
|
|
2306
|
+
for id in frame.removed where paintedIds.contains(id) {
|
|
2307
|
+
await removeLayoutElement(id, layoutId: frame.appId)
|
|
2308
|
+
}
|
|
2309
|
+
for el in frame.elements {
|
|
2310
|
+
if !frame.replay, el.change == "unchanged" { continue }
|
|
2311
|
+
switch el.type {
|
|
2312
|
+
case "text":
|
|
2313
|
+
await drawLayoutText(
|
|
2314
|
+
el.text ?? "", x: el.x, y: el.y, width: el.w, height: el.h,
|
|
2315
|
+
borderWidth: el.border, borderRadius: el.radius,
|
|
2316
|
+
elementId: el.id, layoutId: frame.appId
|
|
2317
|
+
)
|
|
2318
|
+
case "rect":
|
|
2319
|
+
await drawLayoutText(
|
|
2320
|
+
"", x: el.x, y: el.y, width: el.w, height: el.h,
|
|
2321
|
+
borderWidth: max(1, el.border), borderRadius: el.radius,
|
|
2322
|
+
elementId: el.id, layoutId: frame.appId
|
|
2323
|
+
)
|
|
2324
|
+
case "image":
|
|
2325
|
+
if let data = el.data {
|
|
2326
|
+
_ = await drawLayoutBitmap(
|
|
2327
|
+
base64ImageData: data, x: el.x, y: el.y, width: el.w, height: el.h,
|
|
2328
|
+
elementId: el.id, layoutId: frame.appId
|
|
2329
|
+
)
|
|
2330
|
+
}
|
|
2331
|
+
default:
|
|
2332
|
+
Bridge.log("G2: applySceneFrame: unknown element type \(el.type)")
|
|
2333
|
+
}
|
|
2334
|
+
}
|
|
2335
|
+
for id in frame.removed where !paintedIds.contains(id) {
|
|
2336
|
+
await removeLayoutElement(id, layoutId: frame.appId)
|
|
2337
|
+
}
|
|
2338
|
+
sceneBatchDepth -= 1
|
|
2339
|
+
if sceneStructuralPending {
|
|
2340
|
+
sceneStructuralPending = false
|
|
2341
|
+
Bridge.log("G2: applySceneFrame — structural changes, ONE coalesced rebuild for the whole frame")
|
|
2342
|
+
await coalescedPageRebuild()
|
|
2343
|
+
}
|
|
2344
|
+
}
|
|
2345
|
+
|
|
2346
|
+
func onSceneReplay(_: String) async {
|
|
2347
|
+
// A replay frame repaints from scratch through the create path; forget
|
|
2348
|
+
// the element mapping so creates re-match/re-register cleanly.
|
|
2349
|
+
sceneTextByElement.removeAll()
|
|
2350
|
+
sceneImageByElement.removeAll()
|
|
2351
|
+
}
|
|
2352
|
+
|
|
2353
|
+
func drawLayoutText(
|
|
2354
|
+
_ text: String, x: Int32, y: Int32, width: Int32, height: Int32,
|
|
2355
|
+
borderWidth: Int32, borderRadius: Int32, elementId: String, layoutId _: String?
|
|
2356
|
+
) async {
|
|
2357
|
+
// Same firmware min-height guard as sendTextAt, applied before the
|
|
2358
|
+
// registry rect checks so grown rects stay consistent across calls.
|
|
2359
|
+
// y is clamped first so the height term can't go negative.
|
|
2360
|
+
let y = min(max(y, 0), 288 - G2.minTextContainerHeight)
|
|
2361
|
+
let height = min(max(height, G2.minTextContainerHeight), 288 - y)
|
|
2362
|
+
let content = text.isEmpty ? " " : text
|
|
2363
|
+
if let existingId = sceneTextByElement[elementId] {
|
|
2364
|
+
if let i = textContainers.firstIndex(where: { $0.id == existingId }) {
|
|
2365
|
+
let c = textContainers[i]
|
|
2366
|
+
if c.x == x, c.y == y, c.width == width, c.height == height,
|
|
2367
|
+
c.borderWidth == borderWidth, c.borderRadius == borderRadius
|
|
2368
|
+
{
|
|
2369
|
+
// Content-only change: update in place — NEVER a page
|
|
2370
|
+
// rebuild. On G2 this is correctness, not perf: page
|
|
2371
|
+
// teardown couples to mic state and firmware recovery
|
|
2372
|
+
// storms (design doc §3.4.1 hard rule).
|
|
2373
|
+
textContainers[i].content = content
|
|
2374
|
+
textContainers[i].pendingSends = 1 + EVEN_HUB_RESEND_COUNT
|
|
2375
|
+
signalDisplayDirty()
|
|
2376
|
+
return
|
|
2377
|
+
}
|
|
2378
|
+
// Moved/restyled: recreate at the SAME container id (structural).
|
|
2379
|
+
textContainers[i] = TextContainer(
|
|
2380
|
+
id: existingId, x: x, y: y, width: width, height: height, content: content,
|
|
2381
|
+
borderWidth: borderWidth, borderColor: G2.defaultTextContainer.borderColor,
|
|
2382
|
+
borderRadius: borderRadius, paddingLength: G2.defaultTextContainer.paddingLength,
|
|
2383
|
+
pendingSends: 1 + EVEN_HUB_RESEND_COUNT
|
|
2384
|
+
)
|
|
2385
|
+
signalDisplayDirty()
|
|
2386
|
+
await requestPageRebuild()
|
|
2387
|
+
return
|
|
2388
|
+
}
|
|
2389
|
+
// Container got evicted underneath us — fall through to create.
|
|
2390
|
+
sceneTextByElement.removeValue(forKey: elementId)
|
|
2391
|
+
}
|
|
2392
|
+
|
|
2393
|
+
await sendTextAt(
|
|
2394
|
+
content, x: x, y: y, width: width, height: height,
|
|
2395
|
+
borderWidth: borderWidth, borderRadius: borderRadius
|
|
2396
|
+
)
|
|
2397
|
+
if let i = textContainers.firstIndex(where: {
|
|
2398
|
+
$0.matches(
|
|
2399
|
+
x: x, y: y, width: width, height: height, borderWidth: borderWidth,
|
|
2400
|
+
borderColor: G2.defaultTextContainer.borderColor, borderRadius: borderRadius,
|
|
2401
|
+
paddingLength: G2.defaultTextContainer.paddingLength)
|
|
2402
|
+
}) {
|
|
2403
|
+
let cid = textContainers[i].id
|
|
2404
|
+
// The container id may have been LRU-recycled from another element.
|
|
2405
|
+
sceneTextByElement = sceneTextByElement.filter { $0.value != cid }
|
|
2406
|
+
sceneTextByElement[elementId] = cid
|
|
2407
|
+
}
|
|
2408
|
+
}
|
|
2409
|
+
|
|
2410
|
+
func drawLayoutBitmap(
|
|
2411
|
+
base64ImageData: String, x: Int32, y: Int32, width: Int32, height: Int32,
|
|
2412
|
+
elementId: String, layoutId _: String?
|
|
2413
|
+
) async -> Bool {
|
|
2414
|
+
let tiles = G2.imageTileRects(w: width, h: height)
|
|
2415
|
+
guard tiles.count <= imageContainerIDPool.count else {
|
|
2416
|
+
Bridge.log(
|
|
2417
|
+
"G2: drawLayoutBitmap '\(elementId)' \(width)x\(height) needs \(tiles.count) tiles — exceeds the \(imageContainerIDPool.count)-container pool, dropping"
|
|
2418
|
+
)
|
|
2419
|
+
return false
|
|
2420
|
+
}
|
|
2421
|
+
|
|
2422
|
+
// Element moved or re-tiled: blacken any containers whose rects no
|
|
2423
|
+
// longer belong to this element's tile set, then let the tile upserts
|
|
2424
|
+
// reuse/place the rest.
|
|
2425
|
+
if let existing = sceneImageByElement[elementId] {
|
|
2426
|
+
let wantedRects = tiles.map { t in (x + t.dx, y + t.dy, t.w, t.h) }
|
|
2427
|
+
for cid in existing {
|
|
2428
|
+
if let i = imageContainers.firstIndex(where: { $0.id == cid }) {
|
|
2429
|
+
let c = imageContainers[i]
|
|
2430
|
+
let stillWanted = wantedRects.contains { $0 == (c.x, c.y, c.width, c.height) }
|
|
2431
|
+
if !stillWanted, !c.bmpData.isEmpty {
|
|
2432
|
+
imageContainers[i].bmpData = Data()
|
|
2433
|
+
imageContainers[i].dirty = true
|
|
2434
|
+
signalDisplayDirty()
|
|
2435
|
+
}
|
|
2436
|
+
}
|
|
2437
|
+
}
|
|
2438
|
+
}
|
|
2439
|
+
sceneImageByElement.removeValue(forKey: elementId)
|
|
2440
|
+
|
|
2441
|
+
var cids: [Int32] = []
|
|
2442
|
+
if tiles.count == 1 {
|
|
2443
|
+
// Single container — the existing path (decode, aspect-fit, encode).
|
|
2444
|
+
let ok = await displayBitmap(
|
|
2445
|
+
base64ImageData: base64ImageData, x: x, y: y, width: width, height: height
|
|
2446
|
+
)
|
|
2447
|
+
guard ok,
|
|
2448
|
+
let i = imageContainers.firstIndex(where: {
|
|
2449
|
+
$0.matches(x: x, y: y, width: width, height: height)
|
|
2450
|
+
})
|
|
2451
|
+
else { return false }
|
|
2452
|
+
cids = [imageContainers[i].id]
|
|
2453
|
+
} else {
|
|
2454
|
+
// Tiled: render the whole image to grayscale ONCE at the element
|
|
2455
|
+
// size, then slice per-tile rows into their own 4-bit BMPs, one
|
|
2456
|
+
// firmware container per tile.
|
|
2457
|
+
guard let rawData = Data(base64Encoded: base64ImageData) else {
|
|
2458
|
+
Bridge.log("G2: drawLayoutBitmap - failed to decode base64")
|
|
2459
|
+
return false
|
|
2460
|
+
}
|
|
2461
|
+
guard let gray = renderG2Grayscale(rawData, targetWidth: Int(width), targetHeight: Int(height)) else {
|
|
2462
|
+
Bridge.log("G2: drawLayoutBitmap - failed to render grayscale")
|
|
2463
|
+
return false
|
|
2464
|
+
}
|
|
2465
|
+
Bridge.log("G2: drawLayoutBitmap '\(elementId)' \(width)x\(height) → \(tiles.count) tiles")
|
|
2466
|
+
for t in tiles {
|
|
2467
|
+
var tilePixels = Data(capacity: Int(t.w * t.h))
|
|
2468
|
+
for row in 0 ..< Int(t.h) {
|
|
2469
|
+
let start = (Int(t.dy) + row) * Int(width) + Int(t.dx)
|
|
2470
|
+
tilePixels.append(gray.subdata(in: (gray.startIndex + start)..<(gray.startIndex + start + Int(t.w))))
|
|
2471
|
+
}
|
|
2472
|
+
guard let bmp = build4BitBmp(grayscalePixels: tilePixels, width: Int(t.w), height: Int(t.h)) else {
|
|
2473
|
+
Bridge.log("G2: drawLayoutBitmap - tile encode failed")
|
|
2474
|
+
return false
|
|
2475
|
+
}
|
|
2476
|
+
let tx = x + t.dx
|
|
2477
|
+
let ty = y + t.dy
|
|
2478
|
+
if let i = imageContainers.firstIndex(where: { $0.matches(x: tx, y: ty, width: t.w, height: t.h) }) {
|
|
2479
|
+
imageContainers[i].bmpData = bmp
|
|
2480
|
+
imageContainers[i].dirty = true
|
|
2481
|
+
cids.append(imageContainers[i].id)
|
|
2482
|
+
} else {
|
|
2483
|
+
let container = addImageContainer(x: tx, y: ty, width: t.w, height: t.h, bmpData: bmp)
|
|
2484
|
+
if let j = imageContainers.firstIndex(where: { $0.id == container.id }) {
|
|
2485
|
+
imageContainers[j].dirty = true
|
|
2486
|
+
}
|
|
2487
|
+
cids.append(container.id)
|
|
2488
|
+
await requestPageRebuild()
|
|
2489
|
+
}
|
|
2490
|
+
}
|
|
2491
|
+
signalDisplayDirty()
|
|
2492
|
+
}
|
|
2493
|
+
|
|
2494
|
+
// Container ids may have been LRU-recycled from other elements.
|
|
2495
|
+
let taken = Set(cids)
|
|
2496
|
+
for (key, value) in sceneImageByElement where value.contains(where: taken.contains) {
|
|
2497
|
+
sceneImageByElement[key] = value.filter { !taken.contains($0) }
|
|
2498
|
+
if sceneImageByElement[key]?.isEmpty == true {
|
|
2499
|
+
sceneImageByElement.removeValue(forKey: key)
|
|
2500
|
+
}
|
|
2501
|
+
}
|
|
2502
|
+
sceneImageByElement[elementId] = cids
|
|
2503
|
+
return true
|
|
2504
|
+
}
|
|
2505
|
+
|
|
2506
|
+
func removeLayoutElement(_ elementId: String, layoutId _: String?) async {
|
|
2507
|
+
// STRUCTURAL removal: blanked-in-place containers still RENDER (the
|
|
2508
|
+
// firmware draws a cursor-like mark at a whitespace container's content
|
|
2509
|
+
// origin — the long-hunted stray tick; legacy never saw it because its
|
|
2510
|
+
// only container's origin sat above the visible eyebox). So a removed
|
|
2511
|
+
// element leaves the page entirely: blank it for the live page, drop it
|
|
2512
|
+
// from the tracked list (freeing the pool id), and mark the frame
|
|
2513
|
+
// structural — the batched frame-end rebuild recreates the page without
|
|
2514
|
+
// it. Never a per-remove shutdown (mic coupling).
|
|
2515
|
+
if let id = sceneTextByElement.removeValue(forKey: elementId),
|
|
2516
|
+
let i = textContainers.firstIndex(where: { $0.id == id })
|
|
2517
|
+
{
|
|
2518
|
+
textContainers.remove(at: i)
|
|
2519
|
+
await requestPageRebuild()
|
|
2520
|
+
}
|
|
2521
|
+
if let ids = sceneImageByElement.removeValue(forKey: elementId) {
|
|
2522
|
+
for id in ids {
|
|
2523
|
+
if let i = imageContainers.firstIndex(where: { $0.id == id }),
|
|
2524
|
+
!imageContainers[i].bmpData.isEmpty
|
|
2525
|
+
{
|
|
2526
|
+
imageContainers[i].bmpData = Data()
|
|
2527
|
+
imageContainers[i].dirty = true
|
|
2528
|
+
}
|
|
2529
|
+
}
|
|
2530
|
+
signalDisplayDirty()
|
|
2531
|
+
await requestPageRebuild()
|
|
2532
|
+
}
|
|
2533
|
+
}
|
|
2534
|
+
|
|
2535
|
+
/// Sweep a set of scene elements as ONE batched structural change (called
|
|
2536
|
+
/// by DeviceManager on scene→legacy and cross-app transitions, outside any
|
|
2537
|
+
/// applySceneFrame batch — without batching, each remove would rebuild).
|
|
2538
|
+
func clearSceneElements(_ elementIds: [String]) async {
|
|
2539
|
+
sceneBatchDepth += 1
|
|
2540
|
+
for id in elementIds {
|
|
2541
|
+
await removeLayoutElement(id, layoutId: nil)
|
|
2542
|
+
}
|
|
2543
|
+
sceneBatchDepth -= 1
|
|
2544
|
+
if sceneStructuralPending {
|
|
2545
|
+
sceneStructuralPending = false
|
|
2546
|
+
await coalescedPageRebuild()
|
|
2547
|
+
}
|
|
2548
|
+
}
|
|
2549
|
+
|
|
2550
|
+
/// Decode an image and render it to raw 8-bit grayscale at target size
|
|
2551
|
+
/// (aspect-fit, centered on black) — the front half of `convertToG2Bmp`,
|
|
2552
|
+
/// exposed for the tiler which encodes per-tile BMPs from one render.
|
|
2553
|
+
private func renderG2Grayscale(_ data: Data, targetWidth: Int, targetHeight: Int) -> Data? {
|
|
2554
|
+
guard let image = UIImage(data: data), let cgImage = image.cgImage else { return nil }
|
|
2555
|
+
let scale = min(
|
|
2556
|
+
Double(targetWidth) / Double(cgImage.width), Double(targetHeight) / Double(cgImage.height)
|
|
2557
|
+
)
|
|
2558
|
+
let scaledW = max(1, Int(Double(cgImage.width) * scale))
|
|
2559
|
+
let scaledH = max(1, Int(Double(cgImage.height) * scale))
|
|
2560
|
+
let offsetX = (targetWidth - scaledW) / 2
|
|
2561
|
+
let offsetY = (targetHeight - scaledH) / 2
|
|
2562
|
+
|
|
2563
|
+
var pixels = Data(count: targetWidth * targetHeight)
|
|
2564
|
+
let drawn: Bool = pixels.withUnsafeMutableBytes { buf in
|
|
2565
|
+
guard
|
|
2566
|
+
let ctx = CGContext(
|
|
2567
|
+
data: buf.baseAddress,
|
|
2568
|
+
width: targetWidth,
|
|
2569
|
+
height: targetHeight,
|
|
2570
|
+
bitsPerComponent: 8,
|
|
2571
|
+
bytesPerRow: targetWidth,
|
|
2572
|
+
space: CGColorSpaceCreateDeviceGray(),
|
|
2573
|
+
bitmapInfo: CGImageAlphaInfo.none.rawValue
|
|
2574
|
+
)
|
|
2575
|
+
else { return false }
|
|
2576
|
+
ctx.setFillColor(gray: 0, alpha: 1)
|
|
2577
|
+
ctx.fill(CGRect(x: 0, y: 0, width: targetWidth, height: targetHeight))
|
|
2578
|
+
ctx.interpolationQuality = .high
|
|
2579
|
+
ctx.draw(cgImage, in: CGRect(x: offsetX, y: offsetY, width: scaledW, height: scaledH))
|
|
2580
|
+
return true
|
|
2581
|
+
}
|
|
2582
|
+
return drawn ? pixels : nil
|
|
2583
|
+
}
|
|
2584
|
+
|
|
2212
2585
|
func clearDisplay() {
|
|
2213
2586
|
Bridge.log("G2: clearDisplay()")
|
|
2214
2587
|
// Blank the text in place — do NOT shut down + rebuild the page. A teardown kills audio
|
|
2215
2588
|
// and triggers a firmware systemExit→recovery→rebuild; the cloud sends clearDisplay in
|
|
2216
2589
|
// bursts, so that turned into a rebuild storm. The reconcile loop pushes the blanked text.
|
|
2217
2590
|
for i in textContainers.indices {
|
|
2218
|
-
textContainers[i].content = "
|
|
2591
|
+
textContainers[i].content = "\n"
|
|
2219
2592
|
textContainers[i].pendingSends = 1 + EVEN_HUB_RESEND_COUNT
|
|
2220
2593
|
}
|
|
2221
2594
|
for i in imageContainers.indices where !imageContainers[i].bmpData.isEmpty {
|
|
@@ -2227,6 +2600,27 @@ class G2: NSObject, SGCManager {
|
|
|
2227
2600
|
imageContainers[i].dirty = true
|
|
2228
2601
|
}
|
|
2229
2602
|
signalDisplayDirty()
|
|
2603
|
+
|
|
2604
|
+
// Purge scene HUD containers structurally: a blanked small box still
|
|
2605
|
+
// renders the firmware's overflow tick (a "\n" husk is two empty lines
|
|
2606
|
+
// in a one-line box) and corrupts whatever app draws next. Full-canvas
|
|
2607
|
+
// containers stay blanked-in-place — the shipped caption-gap behavior,
|
|
2608
|
+
// storm-safe (no rebuild on ordinary clears). Only when positioned HUD
|
|
2609
|
+
// husks exist (app exit) do we drop them + rebuild ONCE.
|
|
2610
|
+
let isFullCanvas: (TextContainer) -> Bool = {
|
|
2611
|
+
$0.x == 0 && $0.y == 0 && $0.width >= G2.defaultTextContainer.width
|
|
2612
|
+
&& $0.height >= G2.defaultTextContainer.height
|
|
2613
|
+
}
|
|
2614
|
+
let huskIds = Set(textContainers.filter { !isFullCanvas($0) }.map { $0.id })
|
|
2615
|
+
if !huskIds.isEmpty {
|
|
2616
|
+
textContainers.removeAll { huskIds.contains($0.id) }
|
|
2617
|
+
sceneTextByElement = sceneTextByElement.filter { !huskIds.contains($0.value) }
|
|
2618
|
+
sceneImageByElement.removeAll()
|
|
2619
|
+
Bridge.log("G2: clearDisplay() — purging \(huskIds.count) positioned husk container(s), one rebuild")
|
|
2620
|
+
Task { [weak self] in
|
|
2621
|
+
await self?.coalescedPageRebuild()
|
|
2622
|
+
}
|
|
2623
|
+
}
|
|
2230
2624
|
}
|
|
2231
2625
|
|
|
2232
2626
|
/// Send a bitmap to an image container as fragmented updateImageRawData packets.
|
|
@@ -2390,7 +2784,7 @@ class G2: NSObject, SGCManager {
|
|
|
2390
2784
|
// A brand-new page needs its structure built before the loop can push pixels; the dirty
|
|
2391
2785
|
// flag stays set so the reconcile loop sends the image once the page exists.
|
|
2392
2786
|
if !pageCreated {
|
|
2393
|
-
await
|
|
2787
|
+
await requestPageRebuild()
|
|
2394
2788
|
}
|
|
2395
2789
|
} else {
|
|
2396
2790
|
let container = addImageContainer(x: rx, y: ry, width: rw, height: rh, bmpData: bmpData)
|
|
@@ -2402,7 +2796,7 @@ class G2: NSObject, SGCManager {
|
|
|
2402
2796
|
"G2: displayBitmap() - added container \(container.id) for rect \(rx),\(ry) \(rw)x\(rh), rebuilding page"
|
|
2403
2797
|
)
|
|
2404
2798
|
// New container changes page structure: rebuild it, then the loop sends the pixels.
|
|
2405
|
-
await
|
|
2799
|
+
await requestPageRebuild()
|
|
2406
2800
|
}
|
|
2407
2801
|
return true
|
|
2408
2802
|
}
|
|
@@ -2438,6 +2832,11 @@ class G2: NSObject, SGCManager {
|
|
|
2438
2832
|
// Text: synchronous, no ACK. Send one update per container with pending sends and decrement.
|
|
2439
2833
|
for i in textContainers.indices where textContainers[i].pendingSends > 0 {
|
|
2440
2834
|
let container = textContainers[i]
|
|
2835
|
+
// Byte-exact content log (debugDescription escapes \n and non-ASCII)
|
|
2836
|
+
// — artifact forensics: match on-glass rendering to what was sent.
|
|
2837
|
+
Bridge.log(
|
|
2838
|
+
"G2: updateText id=\(container.id) rect=\(container.x),\(container.y) \(container.width)x\(container.height) bytes=\(container.content.utf8.count) content=\(container.content.debugDescription)"
|
|
2839
|
+
)
|
|
2441
2840
|
let msg = EvenHubProto.updateTextMessage(
|
|
2442
2841
|
containerID: container.id,
|
|
2443
2842
|
contentOffset: 0,
|
|
@@ -2996,21 +3395,47 @@ class G2: NSObject, SGCManager {
|
|
|
2996
3395
|
// MARK: - Private Display Helpers
|
|
2997
3396
|
|
|
2998
3397
|
private func createPageWithContainers() {
|
|
2999
|
-
//
|
|
3000
|
-
//
|
|
3001
|
-
|
|
3002
|
-
|
|
3398
|
+
// Dedicated event-capture container: id 0, 1x1, borderless, empty — the
|
|
3399
|
+
// designated event-capture slot per the RE demos ("container 0 is
|
|
3400
|
+
// event-capture"). Touch events keep flowing through it, and no REAL
|
|
3401
|
+
// container carries the flag: marking whichever container happened to
|
|
3402
|
+
// be first (the old behavior) painted a visible artifact once pages
|
|
3403
|
+
// stopped being one full-screen box — the stray line Alex saw
|
|
3404
|
+
// persisting across miniapps.
|
|
3405
|
+
var textContainerProps: [Data] = [
|
|
3406
|
+
EvenHubProto.textContainerProperty(
|
|
3407
|
+
x: 0, y: 0, width: 1, height: 1,
|
|
3408
|
+
borderWidth: 0, borderColor: 0, borderRadius: 0,
|
|
3409
|
+
paddingLength: 0, containerID: 0,
|
|
3410
|
+
containerName: "evt-0", isEventCapture: true,
|
|
3411
|
+
content: ""
|
|
3412
|
+
),
|
|
3413
|
+
]
|
|
3414
|
+
for c in textContainers {
|
|
3003
3415
|
textContainerProps.append(
|
|
3004
3416
|
EvenHubProto.textContainerProperty(
|
|
3005
3417
|
x: c.x, y: c.y, width: c.width, height: c.height,
|
|
3006
3418
|
borderWidth: c.borderWidth, borderColor: c.borderColor,
|
|
3007
3419
|
borderRadius: c.borderRadius,
|
|
3008
3420
|
paddingLength: c.paddingLength, containerID: c.id,
|
|
3009
|
-
containerName: c.name, isEventCapture:
|
|
3421
|
+
containerName: c.name, isEventCapture: false,
|
|
3010
3422
|
content: c.content
|
|
3011
3423
|
))
|
|
3012
3424
|
}
|
|
3013
3425
|
|
|
3426
|
+
// Page-composition dump: one line per container on every page create,
|
|
3427
|
+
// so an on-glass artifact can be matched to exactly what we sent.
|
|
3428
|
+
for c in textContainers {
|
|
3429
|
+
Bridge.log(
|
|
3430
|
+
"G2: page-comp text id=\(c.id) rect=\(c.x),\(c.y) \(c.width)x\(c.height) border=\(c.borderWidth)/\(c.borderRadius) pad=\(c.paddingLength) contentLen=\(c.content.count)"
|
|
3431
|
+
)
|
|
3432
|
+
}
|
|
3433
|
+
for c in imageContainers {
|
|
3434
|
+
Bridge.log(
|
|
3435
|
+
"G2: page-comp image id=\(c.id) rect=\(c.x),\(c.y) \(c.width)x\(c.height) bytes=\(c.bmpData.count)"
|
|
3436
|
+
)
|
|
3437
|
+
}
|
|
3438
|
+
|
|
3014
3439
|
// iterate all image containers, remove any entrys with duplicate id's, and ensure the ids in the imageContainerIDPool is up-to-date:
|
|
3015
3440
|
var seenIDs = Set<Int32>()
|
|
3016
3441
|
imageContainers = imageContainers.filter { c in
|
|
@@ -3761,6 +4186,8 @@ class G2: NSObject, SGCManager {
|
|
|
3761
4186
|
handleEvenAIResponse(result.payload)
|
|
3762
4187
|
case ServiceID.evenHubCtrl.rawValue:
|
|
3763
4188
|
handleEvenHubCtrlResponse(result.payload)
|
|
4189
|
+
case ServiceID.notification.rawValue:
|
|
4190
|
+
handleNotificationResponse(result.payload)
|
|
3764
4191
|
default:
|
|
3765
4192
|
Bridge.log(
|
|
3766
4193
|
"G2: Unhandled service \(result.serviceId) (\(result.payload.count) bytes): \(result.payload.map { String(format: "%02X", $0) }.joined())"
|
|
@@ -3768,6 +4195,86 @@ class G2: NSObject, SGCManager {
|
|
|
3768
4195
|
}
|
|
3769
4196
|
}
|
|
3770
4197
|
|
|
4198
|
+
/// Notification service (0x04 — UI_FOREGROUND_NOTIFICATION_ID).
|
|
4199
|
+
///
|
|
4200
|
+
/// On iOS the G2 reads notifications straight from the phone via ANCS (the
|
|
4201
|
+
/// system BLE notification service — no app code involved), and reports
|
|
4202
|
+
/// notification activity to us on this channel as a NotificationDataPackage
|
|
4203
|
+
/// (notification.proto from the RE work):
|
|
4204
|
+
/// field 1: commandId (1=CTRL, 2=NOTIFICATION_IOS, 3=WHITELIST_CTRL, 161=COMM_RSP)
|
|
4205
|
+
/// field 3: NotificationControl {notifEnable, autoDispEnable, dispTime, avoidDisturbEnable}
|
|
4206
|
+
/// field 4: NotificationIOS {appID, displayName} — the source app of a notification
|
|
4207
|
+
/// field 6: NotificationWhitelistCtrl {whitelistDisable}
|
|
4208
|
+
///
|
|
4209
|
+
/// Log-only for now: this is the observability hook for the notification
|
|
4210
|
+
/// relay work (the CTRL/WHITELIST commands are also how the app would
|
|
4211
|
+
/// control notification behavior on-glass).
|
|
4212
|
+
private func handleNotificationResponse(_ payload: Data) {
|
|
4213
|
+
var reader = ProtobufReader(payload)
|
|
4214
|
+
let fields = reader.parseFields()
|
|
4215
|
+
let cmd = fields[1] as? Int32 ?? 0
|
|
4216
|
+
|
|
4217
|
+
var detail = ""
|
|
4218
|
+
if let iosData = fields[4] as? Data {
|
|
4219
|
+
var ios = ProtobufReader(iosData)
|
|
4220
|
+
let iosFields = ios.parseFields()
|
|
4221
|
+
let appID = (iosFields[1] as? Data).flatMap { String(data: $0, encoding: .utf8) }?
|
|
4222
|
+
.trimmingCharacters(in: CharacterSet(charactersIn: "\0")) ?? "?"
|
|
4223
|
+
let name = (iosFields[2] as? Data).flatMap { String(data: $0, encoding: .utf8) }?
|
|
4224
|
+
.trimmingCharacters(in: CharacterSet(charactersIn: "\0")) ?? "?"
|
|
4225
|
+
detail = " ios={app: \(appID), name: \(name)}"
|
|
4226
|
+
}
|
|
4227
|
+
if let ctrlData = fields[3] as? Data {
|
|
4228
|
+
var ctrl = ProtobufReader(ctrlData)
|
|
4229
|
+
let ctrlFields = ctrl.parseFields()
|
|
4230
|
+
detail += " ctrl={enable: \(ctrlFields[1] as? Int32 ?? -1), autoDisp: \(ctrlFields[2] as? Int32 ?? -1), dispTime: \(ctrlFields[3] as? Int32 ?? -1), dnd: \(ctrlFields[5] as? Int32 ?? -1)}"
|
|
4231
|
+
}
|
|
4232
|
+
if let wlData = fields[6] as? Data {
|
|
4233
|
+
var wl = ProtobufReader(wlData)
|
|
4234
|
+
let wlFields = wl.parseFields()
|
|
4235
|
+
detail += " whitelist={disable: \(wlFields[1] as? Int32 ?? -1)}"
|
|
4236
|
+
}
|
|
4237
|
+
|
|
4238
|
+
let cmdName: String
|
|
4239
|
+
switch cmd {
|
|
4240
|
+
case 1: cmdName = "CTRL"
|
|
4241
|
+
case 2: cmdName = "NOTIFICATION_IOS"
|
|
4242
|
+
case 3: cmdName = "WHITELIST_CTRL"
|
|
4243
|
+
case 161: cmdName = "COMM_RSP"
|
|
4244
|
+
default: cmdName = "cmd_\(cmd)"
|
|
4245
|
+
}
|
|
4246
|
+
Bridge.log("G2: NOTIFICATION service — \(cmdName)\(detail)")
|
|
4247
|
+
|
|
4248
|
+
// Pipe NOTIFICATION_IOS up as a phone_notification event — the same
|
|
4249
|
+
// path Android's NotificationListenerService feeds. iOS can't read
|
|
4250
|
+
// other apps' notifications, but the glasses CAN (ANCS) and report the
|
|
4251
|
+
// source app here; title/content are empty because this packet only
|
|
4252
|
+
// carries app identity.
|
|
4253
|
+
if cmd == 2, let iosData = fields[4] as? Data {
|
|
4254
|
+
var ios = ProtobufReader(iosData)
|
|
4255
|
+
let iosFields = ios.parseFields()
|
|
4256
|
+
let appID = (iosFields[1] as? Data).flatMap { String(data: $0, encoding: .utf8) }?
|
|
4257
|
+
.trimmingCharacters(in: CharacterSet(charactersIn: "\0")) ?? ""
|
|
4258
|
+
let name = (iosFields[2] as? Data).flatMap { String(data: $0, encoding: .utf8) }?
|
|
4259
|
+
.trimmingCharacters(in: CharacterSet(charactersIn: "\0")) ?? ""
|
|
4260
|
+
if !appID.isEmpty {
|
|
4261
|
+
let now = Int64(Date().timeIntervalSince1970 * 1000)
|
|
4262
|
+
Bridge.sendTypedMessage(
|
|
4263
|
+
"phone_notification",
|
|
4264
|
+
body: [
|
|
4265
|
+
"notificationId": "ancs-\(appID)-\(now)",
|
|
4266
|
+
"app": name.isEmpty ? appID : name,
|
|
4267
|
+
"title": "",
|
|
4268
|
+
"content": "",
|
|
4269
|
+
"priority": "0",
|
|
4270
|
+
"timestamp": now,
|
|
4271
|
+
"packageName": appID,
|
|
4272
|
+
]
|
|
4273
|
+
)
|
|
4274
|
+
}
|
|
4275
|
+
}
|
|
4276
|
+
}
|
|
4277
|
+
|
|
3771
4278
|
/// EvenAI service (0x07). Logs the decoded EvenAIDataPackage so we can read the
|
|
3772
4279
|
/// CONFIG (Hey Even) echo: commandId=10 (CONFIG), config sub-message in field 13.
|
|
3773
4280
|
private func handleEvenAIResponse(_ payload: Data) {
|