From f218b47df171bb4209c466427d87874984bec35c Mon Sep 17 00:00:00 2001 From: yash Date: Fri, 26 Jun 2026 18:12:15 -0700 Subject: [PATCH 1/7] chore: parse and format navigation for nimo --- .../java/com/mentra/bluetoothsdk/sgcs/Nimo.kt | 392 +++++++++++++++--- 1 file changed, 326 insertions(+), 66 deletions(-) diff --git a/mobile/modules/bluetooth-sdk/android/src/main/java/com/mentra/bluetoothsdk/sgcs/Nimo.kt b/mobile/modules/bluetooth-sdk/android/src/main/java/com/mentra/bluetoothsdk/sgcs/Nimo.kt index 4eec135e45..a458622109 100644 --- a/mobile/modules/bluetooth-sdk/android/src/main/java/com/mentra/bluetoothsdk/sgcs/Nimo.kt +++ b/mobile/modules/bluetooth-sdk/android/src/main/java/com/mentra/bluetoothsdk/sgcs/Nimo.kt @@ -143,19 +143,30 @@ internal object NimoProtocol { // widget resTypes const val WIDGET_TEXT_NEW = 0x00 const val WIDGET_TEXT_APPEND = 0x01 + const val WIDGET_RAW = 0x02 const val WIDGET_PICTURE = 0x80 // navigation widget resIds (appId 0x01): mini map 0x00, arrow 0x01, turn text 0x02, // status bar 0x03 (raw), tip 0x04, large map 0x05. const val NAV_RES_MINI_MAP = 0x00 + const val NAV_RES_ARROW = 0x01 const val NAV_RES_TURN_TEXT = 0x02 + const val NAV_RES_INFO = 0x03 + const val NAV_RES_TIP = 0x04 const val NAV_RES_LARGE_MAP = 0x05 // navigation image widget sizes (hard firmware requirements; see IMAGE_PROTOCOL). const val NAV_MINI_MAP_SIZE = 160 + const val NAV_ARROW_SIZE = 48 const val NAV_LARGE_MAP_WIDTH = 452 const val NAV_LARGE_MAP_HEIGHT = 170 + // naviInfo status struct (resId 0x03, raw): navType(1) + dist(32 UTF-8) + time(32 UTF-8). + const val NAV_INFO_STRUCT_SIZE = 65 + const val NAV_TYPE_WALK = 0x00 + const val NAV_TYPE_CYCLE = 0x01 + const val NAV_TYPE_EBIKE = 0x02 + // phone types const val PHONE_TYPE_OTHER = 0x02 @@ -986,6 +997,10 @@ class Nimo : SGCManager() { private var textAppEntered = false private var navAppEntered = false private var currentGlassesAppId = -1 + // Last navigation HUD frame pushed, to coalesce the app's ~3s identical re-pushes. + private var lastNavHudKey = "" + // Arrow direction currently shown, so the icon is re-sent only when the turn changes. + private var lastNavArrow: NavArrow? = null // Battery private var lastBatteryLevel = -1 @@ -1179,12 +1194,25 @@ class Nimo : SGCManager() { borderWidth: Int, borderRadius: Int ) { - // Navigation pushes turn text via positioned_text. Nimo widgets have fixed geometry, so - // position/border are ignored — funnel the text through the same coalesced path as - // sendTextWall so it renders on the ASR note page. Without this override the base no-op - // silently dropped all navigation text. - // Bridge.log("NIMO: sendPositionedText(text=$text)") - // pendingText = text + // The navigation app pushes its whole turn-by-turn HUD through positioned_text in one + // combined string (arrow / distance / instruction / ETA). Nimo widgets have fixed + // geometry, so x/y/border are ignored; instead we parse the string and fan the pieces + // out to the dedicated nav widgets (arrow icon, instruction, tip, status struct). + if (handshakeState != HandshakeState.READY) return + val trimmed = text.trim() + // Blank pushes (e.g. the app clearing its loading-message region) must not wipe the + // current HUD — ignore them and keep the last frame on screen. + if (trimmed.isEmpty()) return + val hud = parseNavHud(trimmed) + // Coalesce: the app re-pushes the same frame every ~3s; only redraw on real changes. + val key = hud.toString() + if (key == lastNavHudKey) return + lastNavHudKey = key + Bridge.log( + "NIMO: nav HUD → arrow=${hud.arrow} instr=\"${hud.instruction}\" tip=\"${hud.tip}\" eta=\"${hud.eta}\"" + ) + enterNavAppIfNeeded() + pushNavHud(hud) } override fun displayBitmap( @@ -1195,55 +1223,21 @@ class Nimo : SGCManager() { height: Int? ): Boolean { // Nimo widgets have fixed geometry; x/y are not honored. Renders into the navigation - // large-map widget (appId 0x01, resId 0x05, 452x170 2bpp) — the full-width nav map, - // which shows far more than the small 160x160 mini-map (resId 0x00). bitmapToGrayscale - // aspect-fits onto black, so a non-matching source aspect letterboxes rather than - // distorts. The nav app must be foregrounded before pushing content (mirrors the text - // path). TODO: hardware-verify the large-map widget renders and the nav app-mode. - val targetWidth = NimoProtocol.NAV_LARGE_MAP_WIDTH - val targetHeight = NimoProtocol.NAV_LARGE_MAP_HEIGHT - Bridge.log("NIMO: displayBitmap → nav large map (navAppEntered=$navAppEntered)") + // mini-map widget (appId 0x01, resId 0x00, 160x160 2bpp) — the compact "small screen" + // nav map. bitmapToGrayscale aspect-fits onto black, so a non-matching source aspect + // letterboxes rather than distorts. The nav app must be foregrounded before pushing + // content (mirrors the text path). TODO: hardware-verify the mini-map widget renders. + val targetWidth = NimoProtocol.NAV_MINI_MAP_SIZE + val targetHeight = NimoProtocol.NAV_MINI_MAP_SIZE + Bridge.log("NIMO: displayBitmap → nav mini map (navAppEntered=$navAppEntered)") return try { val imageBytes = Base64.decode(base64ImageData, Base64.DEFAULT) val bitmap = BitmapFactory.decodeByteArray(imageBytes, 0, imageBytes.size) ?: return false val grayscale = bitmapToGrayscale(bitmap, targetWidth, targetHeight) - val packed = packL8To2bpp(grayscale) - val (payload, compression) = compressAdaptive(packed) - if (!navAppEntered) { - sendFrame( - NimoFrameCodec.encodeFrame( - NimoProtocol.CMD_CONTROL_INSTRUCTION, - NimoProtocol.CTRL_ENTER_APP, - byteArrayOf( - NimoProtocol.APP_ID_NAV.toByte(), - NimoProtocol.APP_MODE_STANDALONE.toByte() - ) - ) - ) - // Optimistic; corrected by app-state reports if the glasses refuse/exit. - navAppEntered = true - } - val content = - NimoFrameCodec.imageHeader( - width = targetWidth, - height = targetHeight, - formatBpp = NimoProtocol.FORMAT_2BPP, - compression = compression, - originalSize = packed.size, - compressedSize = - if (compression == NimoProtocol.COMPRESSION_NONE) 0 - else payload.size - ) + payload - val frames = - NimoFrameCodec.updateContentFrames( - appId = NimoProtocol.APP_ID_NAV, - layoutId = 0, - resId = NimoProtocol.NAV_RES_LARGE_MAP, - resType = NimoProtocol.WIDGET_PICTURE, - content = content - ) - enqueueFrames(frames) + val content = buildNavImageContent(grayscale, targetWidth, targetHeight) + enterNavAppIfNeeded() + enqueueNavWidget(NimoProtocol.NAV_RES_MINI_MAP, NimoProtocol.WIDGET_PICTURE, content) true } catch (e: Exception) { Bridge.log("NIMO: displayBitmap failed: ${e.message}") @@ -1251,6 +1245,225 @@ class Nimo : SGCManager() { } } + /** Foregrounds the nav app (appId 0x01) once; corrected by app-state reports. */ + private fun enterNavAppIfNeeded() { + if (navAppEntered) return + sendFrame( + NimoFrameCodec.encodeFrame( + NimoProtocol.CMD_CONTROL_INSTRUCTION, + NimoProtocol.CTRL_ENTER_APP, + byteArrayOf( + NimoProtocol.APP_ID_NAV.toByte(), + NimoProtocol.APP_MODE_STANDALONE.toByte() + ) + ) + ) + navAppEntered = true + } + + // ---------- Navigation HUD Parsing ---------- + + /** Turn direction for the arrow icon; NONE renders a blank icon (no arrow). */ + private enum class NavArrow { + LEFT, + RIGHT, + STRAIGHT, + NONE + } + + /** The parsed pieces of one navigation HUD frame. */ + private data class NavHud( + val arrow: NavArrow, + val instruction: String, + val tip: String, + val distance: String, + val eta: String, + // Whether to show the status struct (dist/ETA bar). Off for welcome/arrival frames. + val showStatus: Boolean + ) + + /** + * Parses the navigation app's combined HUD string into the pieces the Nimo nav widgets + * want. The app crams the whole frame into one positioned_text push, e.g.: + * + * → ← directional arrow glyph (←/→/↑) + * In 198 m ← distance-to-turn ("In X" / "Now" / "Arriving in X") + * Turn right onto Octavia St + * ← blank line + * ⊙ 14 min ← trip ETA + * + * Non-turn frames (Welcome / Rerouting… / Go back / Starting… / You have arrived …) have no + * arrow and no ⊙ line; their text falls through to [NavHud.instruction]. + * + * The instruction is kept short (≤3 words — "Turn right", "Welcome to Maps", "Arrived"); any + * "onto " suffix is moved into the tip line next to the distance so the big line stays + * a glanceable maneuver. + */ + private fun parseNavHud(text: String): NavHud { + var eta = "" + val content = mutableListOf() + for (raw in text.split("\n")) { + val line = raw.trim() + if (line.isEmpty()) continue + when { + // Drop the arrow GLYPH line: the app forces it to "↑" until ~10 m before the turn, + // so it reads "straight" for almost the whole leg. We derive the real direction + // from the maneuver instruction instead (see arrowForInstruction). + line == "←" || line == "→" || line == "↑" || line == "↺" || line == "⇆" -> {} + line.startsWith("⊙") -> eta = line.removePrefix("⊙").trim() + else -> content.add(line) + } + } + // The distance-to-turn line (used as the tip) vs the instruction line. + val distRegex = Regex("^(in\\s|now$|arriving)", RegexOption.IGNORE_CASE) + val distLine = content.firstOrNull { distRegex.containsMatchIn(it) } ?: "" + val rawInstruction = content.filter { it != distLine }.joinToString(" ") + val (instruction, road) = shortenInstruction(rawInstruction) + // Arrow direction from the maneuver text, so it points at the turn the whole approach. + val arrow = arrowForInstruction(instruction) + // Welcome/arrival frames (arrow == NONE) carry no live nav data — hide the status bar. + val showStatus = arrow != NavArrow.NONE + // Tip = distance line + road name (e.g. "In 198 m on Octavia St"); welcome gets a prompt. + var tip = listOf(distLine, road).filter { it.isNotEmpty() }.joinToString(" on ") + if (instruction.startsWith("Welcome")) tip = "Choose a location on the map" + // Numeric distance for the status struct: strip the "In " / "Arriving in " prefix. + val distance = + distLine.replace(Regex("^(arriving\\s+in|in)\\s+", RegexOption.IGNORE_CASE), "").trim() + return NavHud(arrow, instruction, tip, distance, eta, showStatus) + } + + /** Turn direction inferred from the maneuver phrase ("Turn left" → LEFT, else STRAIGHT). */ + private fun arrowForInstruction(instruction: String): NavArrow { + val lower = instruction.lowercase() + return when { + // No maneuver to point at — hide the arrow entirely on these frames. + lower.contains("welcome") || lower.contains("arrived") -> NavArrow.NONE + lower.contains("left") -> NavArrow.LEFT + lower.contains("right") -> NavArrow.RIGHT + else -> NavArrow.STRAIGHT + } + } + + /** + * Reduces a raw instruction to a short maneuver phrase (≤3 words) and the road name it + * referenced. "Turn right onto Octavia St" → ("Turn right", "Octavia St"); welcome/arrival + * frames collapse to fixed short labels. + */ + private fun shortenInstruction(raw: String): Pair { + val trimmed = raw.trim() + if (trimmed.isEmpty()) return Pair("", "") + val lower = trimmed.lowercase() + if (lower.contains("welcome")) return Pair("Welcome to Maps", "") + if (lower.contains("arrived")) return Pair("Arrived!", "") + // Peel off the "onto " suffix (or a leading "Onto ") into the road name. + var maneuver = trimmed + var road = "" + val ontoIdx = lower.indexOf(" onto ") + if (ontoIdx >= 0) { + maneuver = trimmed.substring(0, ontoIdx).trim() + road = trimmed.substring(ontoIdx + 6).trim() + } else if (lower.startsWith("onto ")) { + road = trimmed.substring(5).trim() + maneuver = "" + } + val capped = + maneuver.split(Regex("\\s+")).filter { it.isNotEmpty() }.take(3).joinToString(" ") + return Pair(capped, road) + } + + /** Pushes a parsed HUD frame into the nav widgets (arrow icon / instruction / tip / info). */ + private fun pushNavHud(hud: NavHud) { + // naviInstruction (0x02): primary turn text. + enqueueNavWidget( + NimoProtocol.NAV_RES_TURN_TEXT, + NimoProtocol.WIDGET_TEXT_NEW, + hud.instruction.ifEmpty { " " }.toByteArray(Charsets.UTF_8) + ) + // naviInstructionTip (0x04): distance-to-turn line. + enqueueNavWidget( + NimoProtocol.NAV_RES_TIP, + NimoProtocol.WIDGET_TEXT_NEW, + hud.tip.ifEmpty { " " }.toByteArray(Charsets.UTF_8) + ) + // naviInfo (0x03): 65-byte status struct — navType(1) + dist(32) + time(32). Skipped on + // welcome/arrival frames so no (empty) status bar shows there. + if (hud.showStatus) { + val info = ByteArray(NimoProtocol.NAV_INFO_STRUCT_SIZE) + info[0] = NimoProtocol.NAV_TYPE_WALK.toByte() + writeUtf8Padded(info, 1, hud.distance, 32) + writeUtf8Padded(info, 33, hud.eta, 32) + enqueueNavWidget(NimoProtocol.NAV_RES_INFO, NimoProtocol.WIDGET_RAW, info) + } + // naviIcon (0x01): turn-arrow. Only re-send when the DIRECTION changes — re-pushing the + // identical image on every distance tick made the arrow flicker/"move". The bitmap for + // each direction is rendered once and cached. + if (hud.arrow != lastNavArrow) { + lastNavArrow = hud.arrow + enqueueNavWidget( + NimoProtocol.NAV_RES_ARROW, + NimoProtocol.WIDGET_PICTURE, + navArrowContent(hud.arrow) + ) + } + } + + // Arrow widget content for every direction, rendered ONCE at construction (start time) and + // reused — the bitmaps are deterministic, so the send path just selects the pre-built bytes + // instead of re-rendering on each turn. + private val navArrowCache: Map = + NavArrow.values().associateWith { arrow -> + val bmp = buildNavArrowBitmap(arrow) + val gray = + bitmapToGrayscale( + bmp, + NimoProtocol.NAV_ARROW_SIZE, + NimoProtocol.NAV_ARROW_SIZE + ) + bmp.recycle() + buildNavImageContent(gray, NimoProtocol.NAV_ARROW_SIZE, NimoProtocol.NAV_ARROW_SIZE) + } + + private fun navArrowContent(arrow: NavArrow): ByteArray = navArrowCache.getValue(arrow) + + /** A 48x48 white turn-arrow on black, rotated to point left/right/up per [arrow]. */ + private fun buildNavArrowBitmap(arrow: NavArrow): Bitmap { + val size = NimoProtocol.NAV_ARROW_SIZE + val bmp = Bitmap.createBitmap(size, size, Bitmap.Config.ARGB_8888) + val canvas = Canvas(bmp) + canvas.drawColor(Color.BLACK) + // NONE → leave the icon blank (all black = nothing lit) for welcome/arrival frames. + if (arrow == NavArrow.NONE) return bmp + val paint = + Paint(Paint.ANTI_ALIAS_FLAG).apply { + color = Color.WHITE + style = Paint.Style.FILL + } + val s = size.toFloat() + // Rotate the (upward) arrow around the center to face the turn direction. + val rotation = + when (arrow) { + NavArrow.LEFT -> -90f + NavArrow.RIGHT -> 90f + else -> 0f + } + canvas.save() + canvas.rotate(rotation, s / 2f, s / 2f) + val path = + android.graphics.Path().apply { + moveTo(s * 0.5f, s * 0.15f) // tip + lineTo(s * 0.85f, s * 0.55f) // head bottom-right + lineTo(s * 0.62f, s * 0.55f) + lineTo(s * 0.62f, s * 0.85f) // shaft bottom-right + lineTo(s * 0.38f, s * 0.85f) // shaft bottom-left + lineTo(s * 0.38f, s * 0.55f) + lineTo(s * 0.15f, s * 0.55f) // head bottom-left + close() + } + canvas.drawPath(path, paint) + canvas.restore() + return bmp + } + override fun showDashboard() { Bridge.log("NIMO: showDashboard()") textAppEntered = false @@ -1288,22 +1501,15 @@ class Nimo : SGCManager() { // ---------- SGCManager: Device Control ---------- override fun setHeadUpAngle(angle: Int) { - val clamped = angle.coerceIn(0, 90) - Bridge.log("NIMO: setHeadUpAngle($clamped)") - // Enable the head-up display gesture, then set the wake angle. + Bridge.log("NIMO: setHeadUpAngle($angle) — head-up display disabled for now") + // Head-up display is intentionally kept OFF for now (it auto-wakes the dashboard and the + // wake-angle semantics aren't dialed in). Force it disabled regardless of the requested + // angle. TODO: restore the enable + setAngle [optType, deg] path once verified. sendFrame( NimoFrameCodec.encodeFrame( NimoProtocol.CMD_SET_PARAMETER, NimoProtocol.SET_HEADUP_DISPLAY, - byteArrayOf(1) - ) - ) - // setAngle payload is [optType, deg]. TODO: hardware-verify optType semantics. - sendFrame( - NimoFrameCodec.encodeFrame( - NimoProtocol.CMD_SET_PARAMETER, - NimoProtocol.SET_ANGLE, - byteArrayOf(0x01, clamped.toByte()) + byteArrayOf(0) ) ) } @@ -2091,6 +2297,16 @@ class Nimo : SGCManager() { needsAck = false ) ) + // Disable the on-device head-up gesture so raising your head doesn't auto-wake the + // dashboard. Disabled for now; re-enable once the wake-angle behaviour is dialed in. + sendFrame( + NimoFrameCodec.encodeFrame( + NimoProtocol.CMD_SET_PARAMETER, + NimoProtocol.SET_HEADUP_DISPLAY, + byteArrayOf(0), + needsAck = false + ) + ) getBatteryStatus() requestVersionInfo() @@ -2114,6 +2330,8 @@ class Nimo : SGCManager() { textAppEntered = false navAppEntered = false currentGlassesAppId = -1 + lastNavHudKey = "" + lastNavArrow = null pendingText = null audioClient.stop() receiveAssembler.reset() @@ -2295,12 +2513,21 @@ class Nimo : SGCManager() { NimoProtocol.STATE_ENTER -> { currentGlassesAppId = appId if (appId != textAppId) textAppEntered = false - if (appId != NimoProtocol.APP_ID_NAV) navAppEntered = false + if (appId != NimoProtocol.APP_ID_NAV) { + navAppEntered = false + // Force a full HUD repaint next time the nav app reopens. + lastNavHudKey = "" + lastNavArrow = null + } } NimoProtocol.STATE_EXIT -> { if (appId == currentGlassesAppId) currentGlassesAppId = -1 if (appId == textAppId) textAppEntered = false - if (appId == NimoProtocol.APP_ID_NAV) navAppEntered = false + if (appId == NimoProtocol.APP_ID_NAV) { + navAppEntered = false + lastNavHudKey = "" + lastNavArrow = null + } } } } @@ -2356,11 +2583,14 @@ class Nimo : SGCManager() { when (code) { NimoProtocol.INPUT_HEAD_UP -> { DeviceStore.apply("glasses", "headUp", true) - Bridge.sendHeadUp(true) + // Head-up forwarding to the cloud is disabled for now (the wake-angle + // semantics aren't dialed in yet). Keep the local state; don't notify. + // Bridge.sendHeadUp(true) } NimoProtocol.INPUT_HEAD_DOWN -> { DeviceStore.apply("glasses", "headUp", false) - Bridge.sendHeadUp(false) + // Head-up forwarding to the cloud is disabled for now (see INPUT_HEAD_UP). + // Bridge.sendHeadUp(false) } NimoProtocol.INPUT_CLICK_RIGHT, NimoProtocol.INPUT_CLICK_LEFT -> Bridge.sendTouchEvent(DeviceTypes.NIMO, "single_tap", timestamp) @@ -2446,6 +2676,36 @@ class Nimo : SGCManager() { Bridge.sendVersionInfo(mapOf("firmwareVersion" to version)) } + // ---------- Nav Widget Helpers ---------- + + /** Enqueues a single nav-app content widget (chunked, appId 0x01, layout 0). */ + private fun enqueueNavWidget(resId: Int, resType: Int, content: ByteArray) { + enqueueFrames( + NimoFrameCodec.updateContentFrames( + appId = NimoProtocol.APP_ID_NAV, + layoutId = 0, + resId = resId, + resType = resType, + content = content + ) + ) + } + + /** Builds the [imageHeader + payload] blob for a nav image widget from an L8 buffer. */ + private fun buildNavImageContent(grayscale: ByteArray, width: Int, height: Int): ByteArray { + val packed = packL8To2bpp(grayscale) + val (payload, compression) = compressAdaptive(packed) + return NimoFrameCodec.imageHeader( + width = width, + height = height, + formatBpp = NimoProtocol.FORMAT_2BPP, + compression = compression, + originalSize = packed.size, + compressedSize = + if (compression == NimoProtocol.COMPRESSION_NONE) 0 else payload.size + ) + payload + } + // ---------- Bitmap Helpers ---------- /** Aspect-fit the bitmap into [width]x[height] on black, then convert to L8 grayscale. */ From 251eb0db36f39c936f487b7a9a214fc422d066bd Mon Sep 17 00:00:00 2001 From: yash Date: Fri, 26 Jun 2026 18:41:04 -0700 Subject: [PATCH 2/7] chore: remove the nimo from super mode and point the cloud v2 to debug by default --- mobile/src/app/pairing/select-glasses-model.tsx | 2 +- mobile/src/services/cloudClient.ts | 6 ++++-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/mobile/src/app/pairing/select-glasses-model.tsx b/mobile/src/app/pairing/select-glasses-model.tsx index 782bb24aab..30c5e1cb3e 100644 --- a/mobile/src/app/pairing/select-glasses-model.tsx +++ b/mobile/src/app/pairing/select-glasses-model.tsx @@ -53,7 +53,7 @@ export default function SelectGlassesModelScreen() { } // Glasses models that should only be visible in super mode. - const SUPER_MODE_ONLY_MODELS = new Set([DeviceTypes.NEX, DeviceTypes.NIMO]) + const SUPER_MODE_ONLY_MODELS = new Set([DeviceTypes.NEX]) // Platform-specific glasses options const glassesOptions = diff --git a/mobile/src/services/cloudClient.ts b/mobile/src/services/cloudClient.ts index ec3bcfec06..d5d039354d 100644 --- a/mobile/src/services/cloudClient.ts +++ b/mobile/src/services/cloudClient.ts @@ -32,8 +32,10 @@ type Lc3FrameSizeBytes = 20 | 40 | 60 // Team-friendly defaults for dev builds. Local Cloud V2 is still one tap away // via the METRO_AUTO dev-settings preset; it should not be the invisible // default because it depends on a local stack plus adb reverse/LAN reachability. -const DEFAULT_CORE_URL = "https://core.dev.us-west-2.mentraglass.com" -const DEFAULT_RUNTIME_URL = "https://runtime.dev.us-west-2.mentraglass.com" +// Default to the DEBUG cloud (not dev) so the navigation app lands on debug +// Cloud V2 unless an explicit override/env is set. +const DEFAULT_CORE_URL = "https://core.debug.us-west-2.mentraglass.com" +const DEFAULT_RUNTIME_URL = "https://runtime.debug.us-west-2.mentraglass.com" const CORE_PORT = 3000 const RUNTIME_PORT = 3001 From 038d4b369aa532352399f8e05f3ab7378bd201a3 Mon Sep 17 00:00:00 2001 From: Alex Israelov Date: Fri, 26 Jun 2026 19:45:30 -0700 Subject: [PATCH 3/7] fix for ios --- .../bluetooth-sdk/ios/Source/sgcs/Nimo.swift | 14 ++++++------- .../island/src/utils/miniappGlobals.ts | 21 ++++++++++++++++++- 2 files changed, 27 insertions(+), 8 deletions(-) diff --git a/mobile/modules/bluetooth-sdk/ios/Source/sgcs/Nimo.swift b/mobile/modules/bluetooth-sdk/ios/Source/sgcs/Nimo.swift index a027e31011..a88d711c0f 100644 --- a/mobile/modules/bluetooth-sdk/ios/Source/sgcs/Nimo.swift +++ b/mobile/modules/bluetooth-sdk/ios/Source/sgcs/Nimo.swift @@ -808,6 +808,10 @@ class Nimo: NSObject, SGCManager { pendingText = " " } + func sendText(_ text: String) async { + await sendTextWall(text) + } + func sendTextWall(_ text: String) async { // Coalesced: only the most recent pending text survives until the next 100ms drain. pendingText = text @@ -1024,11 +1028,7 @@ class Nimo: NSObject, SGCManager { // MARK: - SGCManager: Camera & Media (no camera) - func requestPhoto( - _: String, appId _: String, size _: String?, webhookUrl _: String?, authToken _: String?, - compress _: String?, flash _: Bool, save _: Bool, sound _: Bool, exposureTimeNs _: Double?, - iso _: Int? - ) { + func requestPhoto(_: PhotoRequest) { Bridge.log("NIMO: requestPhoto - not supported (no camera)") } @@ -1042,7 +1042,7 @@ class Nimo: NSObject, SGCManager { func sendStreamKeepAlive(_: [String: Any]) {} - func startVideoRecording(requestId _: String, save _: Bool, flash _: Bool, sound _: Bool) { + func startVideoRecording(requestId _: String, save _: Bool, sound _: Bool) { Bridge.log("NIMO: startVideoRecording - not supported") } @@ -1062,7 +1062,7 @@ class Nimo: NSObject, SGCManager { func sendWifiCredentials(_: String, _: String) {} func forgetWifiNetwork(_: String) {} func sendHotspotState(_: Bool) {} - func sendOtaStart() {} + func sendOtaStart(otaVersionUrl _: String?) {} func sendOtaQueryStatus() {} // MARK: - SGCManager: User Context / Gallery / Version diff --git a/mobile/modules/island/src/utils/miniappGlobals.ts b/mobile/modules/island/src/utils/miniappGlobals.ts index e66508535f..e0ddc21946 100644 --- a/mobile/modules/island/src/utils/miniappGlobals.ts +++ b/mobile/modules/island/src/utils/miniappGlobals.ts @@ -70,6 +70,16 @@ export interface BuildMiniappGlobalsOptions { * the top inset so its coords match where it's actually drawn on screen. */ webviewFillsStatusBar?: boolean + /** + * True when the WebView container renders edge-to-edge under the *navigation + * bar* (no bottom safe-area padding applied by the host). In that case we + * default the miniapp's body padding to the bottom inset so its document flow + * sits above the Android 3-button nav bar out of the box (no-op on slim/ + * gesture nav, where the inset is ~0). Leave false when the host already pads + * the container by the bottom inset (e.g. the cloud webview's Screen + * safeAreaEdges), to avoid double-counting it. + */ + webviewFillsNavBar?: boolean /** Current host color scheme. Miniapps may follow this to match the phone. */ colorScheme?: MiniappColorScheme } @@ -125,6 +135,14 @@ export function buildMiniappGlobalsScript(opts: BuildMiniappGlobalsOptions): str .map(([k, v]) => `${k}: ${v};`) .join(" ") + // Default the miniapp's document flow above the Android nav bar, but only + // when the WebView itself fills the area behind it (edge-to-edge SDK host). + // No-op on slim/gesture nav (--mentra-safe-bottom ~0). Overridable by the + // miniapp via `body { padding: 0 }` or fixed/absolute elements. + const bodySafeAreaBlock = opts.webviewFillsNavBar + ? "body { box-sizing: border-box; padding-bottom: var(--mentra-safe-bottom); }" + : "" + // Console-tap shim: wrap console.log/warn/error/info/debug so each call // also forwards a `dev_log` envelope back to the phone via // ReactNativeWebView.postMessage. Dev mode forwards to the laptop's @@ -233,7 +251,8 @@ export function buildMiniappGlobalsScript(opts: BuildMiniappGlobalsOptions): str styleEl.setAttribute("data-mentra-injected", "1"); styleEl.textContent = ":root { ${cssVarsBlock} }" + - "html, body { touch-action: manipulation; -ms-content-zooming: none; }"; + "html, body { touch-action: manipulation; -ms-content-zooming: none; }" + + "${bodySafeAreaBlock}"; (document.head || document.documentElement).appendChild(styleEl); } catch (e) { /* ignore */ } From 3a0e2d66b28ea3420d895c76f2ed2b6b0f83993e Mon Sep 17 00:00:00 2001 From: Alex Israelov Date: Fri, 26 Jun 2026 19:45:40 -0700 Subject: [PATCH 4/7] fix for android 3 button navbar could break stuff tho --- mobile/src/components/miniapp/LocalMiniappView.tsx | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/mobile/src/components/miniapp/LocalMiniappView.tsx b/mobile/src/components/miniapp/LocalMiniappView.tsx index b99bea5fc7..5c36d75cc2 100644 --- a/mobile/src/components/miniapp/LocalMiniappView.tsx +++ b/mobile/src/components/miniapp/LocalMiniappView.tsx @@ -480,6 +480,11 @@ function LocalMiniappView({ right: insets.right, }, webviewFillsStatusBar: true, + // The Compositor renders this WebView edge-to-edge (Screen preset="fixed", + // no bottom safeAreaEdge), so on Android with a 3-button nav bar the bottom + // inset must be applied as the miniapp's default body padding — otherwise + // its content draws behind the nav buttons. + webviewFillsNavBar: true, colorScheme, }) const uiShim = buildMentraUiShim({packageName}) From 12564e107e06bb112cc53ed1c2fbf6200065f775 Mon Sep 17 00:00:00 2001 From: Alex Israelov Date: Sat, 27 Jun 2026 13:53:21 -0700 Subject: [PATCH 5/7] Fix Mentra Maps safe-area instead of padding every miniapp body MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Revert the host-side webviewFillsNavBar hack (LocalMiniappView + miniappGlobals back to dev). Padding every local miniapp's both double-counted the inset for the miniapps that already handle it via useSafeArea(), and did nothing for Maps — whose bottom UI is position:fixed, which body padding never moves. So it neither fixed Maps nor was safe for the others. Fix Maps itself, the way the other miniapps do: consume the host-injected --mentra-safe-bottom inset on the bottom-anchored overlay UI so it clears the Android 3-button nav bar, while the Mapbox canvas stays full-bleed under the bars. - Drawer / NavigationRunningDrawer: pad the OUTER fixed wrapper by the inset (not the measured card, so peek/drag math is unchanged). - NavMap right-rail: anchor above the inset as well as the drawer. - Toast + search-results list: lift/pad by the inset. - Bump miniapp version 1.1.7 -> 1.1.8. NOTE: the bundled zip in mobile/assets/miniapps/ must be repacked (mentra-miniapp release, with PUBLIC_MAPBOX_TOKEN / PUBLIC_MAP_NAV_VIEWER set) and bundledMiniapps regenerated for this to ship at runtime. --- miniapps/navigation/miniapp.json | 2 +- .../src/ui/components/Drawer/Drawer.tsx | 9 +++++++- .../src/ui/components/Toast/Toast.tsx | 5 ++++- .../LocationSearch/LocationSearch.tsx | 4 ++++ .../components/NavMap/NavMap.tsx | 10 +++++++-- .../NavigationRunningDrawer.tsx | 5 +++++ .../island/src/utils/miniappGlobals.ts | 21 +------------------ .../components/miniapp/LocalMiniappView.tsx | 5 ----- 8 files changed, 31 insertions(+), 30 deletions(-) diff --git a/miniapps/navigation/miniapp.json b/miniapps/navigation/miniapp.json index 97b2e2feac..c7dd257aa0 100644 --- a/miniapps/navigation/miniapp.json +++ b/miniapps/navigation/miniapp.json @@ -1,7 +1,7 @@ { "$schema": "./node_modules/@mentra/miniapp-cli/schema/miniapp.schema.json", "packageName": "com.mentra.navigation", - "version": "1.1.7", + "version": "1.1.8", "name": "Mentra Maps", "description": "Turn-by-turn walking navigation with compass and Google Maps.", "icon": "icon.png", diff --git a/miniapps/navigation/src/ui/components/Drawer/Drawer.tsx b/miniapps/navigation/src/ui/components/Drawer/Drawer.tsx index e0c66f8c45..46f3f99e8f 100644 --- a/miniapps/navigation/src/ui/components/Drawer/Drawer.tsx +++ b/miniapps/navigation/src/ui/components/Drawer/Drawer.tsx @@ -200,7 +200,14 @@ function DrawerInner({ } return ( -
+ // Lift the whole drawer above the Android 3-button nav bar. The host + // injects the bottom safe-area inset as `--mentra-safe-bottom` (0 on iOS / + // gesture nav). It's applied to the outer wrapper — NOT the measured card — + // so the peek/drag math (sheetHeight, peekOffset, published DrawerOffset) + // is unchanged; the map stays full-bleed behind everything. +
+ // Sit above the Android 3-button nav bar: base 2.5rem (was + // `bottom-10`) + host-injected bottom inset (0 on iOS / gesture nav). + style={{bottom: "calc(2.5rem + var(--mentra-safe-bottom))"}} + className="pointer-events-none fixed inset-x-0 z-[100] flex justify-center px-6">
{message}
diff --git a/miniapps/navigation/src/ui/pages/NavigationPage/components/LocationSearch/LocationSearch.tsx b/miniapps/navigation/src/ui/pages/NavigationPage/components/LocationSearch/LocationSearch.tsx index d55fb4e636..f242dce81d 100644 --- a/miniapps/navigation/src/ui/pages/NavigationPage/components/LocationSearch/LocationSearch.tsx +++ b/miniapps/navigation/src/ui/pages/NavigationPage/components/LocationSearch/LocationSearch.tsx @@ -294,6 +294,10 @@ export function LocationSearch({selected, onSelect, onClear, disabled, devFrozen // is exempt — browsers always allow selection // inside form controls so the user can still edit their // query normally. + // Full-bleed white panel (bg goes under the bars), but pad the + // scroll content so the last result clears the Android 3-button + // nav bar (host-injected inset; 0 on iOS / gesture nav). + style={{paddingBottom: "var(--mentra-safe-bottom)"}} className={`fixed z-40 inset-x-0 bottom-0 top-0 bg-white overflow-auto select-none [-webkit-touch-callout:none] [-webkit-user-select:none] ${safeHeadingSearchResults}`}> {loading ? (
diff --git a/miniapps/navigation/src/ui/pages/NavigationPage/components/NavMap/NavMap.tsx b/miniapps/navigation/src/ui/pages/NavigationPage/components/NavMap/NavMap.tsx index 52f65b5748..944cf0e95b 100644 --- a/miniapps/navigation/src/ui/pages/NavigationPage/components/NavMap/NavMap.tsx +++ b/miniapps/navigation/src/ui/pages/NavigationPage/components/NavMap/NavMap.tsx @@ -106,10 +106,16 @@ export function NavMap({ const devEnabled = isDev || devOverride // Anchor the floating right-rail (zoom / recenter buttons) just above - // whichever drawer is currently mounted. + // whichever drawer is currently mounted, and above the Android 3-button nav + // bar (host-injected `--mentra-safe-bottom`; 0 on iOS / gesture nav). The + // same inset is applied to the drawers' outer wrappers, so this keeps the + // rail the same 12px above whichever drawer is up. const drawerOffset = useDrawerOffset() const fallbackZero = useMotionValue(0) - const railBottom = useTransform(drawerOffset ?? fallbackZero, (h: number) => h + 12) + const railBottom = useTransform( + drawerOffset ?? fallbackZero, + (h: number) => `calc(${h + 12}px + var(--mentra-safe-bottom))`, + ) useEffect(() => { let alive = true diff --git a/miniapps/navigation/src/ui/pages/NavigationPage/components/NavigationRunningDrawer/NavigationRunningDrawer.tsx b/miniapps/navigation/src/ui/pages/NavigationPage/components/NavigationRunningDrawer/NavigationRunningDrawer.tsx index bdd06962cf..a0e476779d 100644 --- a/miniapps/navigation/src/ui/pages/NavigationPage/components/NavigationRunningDrawer/NavigationRunningDrawer.tsx +++ b/miniapps/navigation/src/ui/pages/NavigationPage/components/NavigationRunningDrawer/NavigationRunningDrawer.tsx @@ -88,6 +88,11 @@ export function NavigationRunningDrawer({ animate={{y: 0}} exit={{y: "100%"}} transition={{type: "spring", stiffness: 320, damping: 42}} + // Lift the running bar above the Android 3-button nav bar via the + // host-injected bottom inset (0 on iOS / gesture nav). On the outer + // wrapper so the measured bar height (published as DrawerOffset for + // the right-rail) is unaffected. + style={{paddingBottom: "var(--mentra-safe-bottom)"}} className="fixed left-0 right-0 bottom-0 z-40 pointer-events-none">
`${k}: ${v};`) .join(" ") - // Default the miniapp's document flow above the Android nav bar, but only - // when the WebView itself fills the area behind it (edge-to-edge SDK host). - // No-op on slim/gesture nav (--mentra-safe-bottom ~0). Overridable by the - // miniapp via `body { padding: 0 }` or fixed/absolute elements. - const bodySafeAreaBlock = opts.webviewFillsNavBar - ? "body { box-sizing: border-box; padding-bottom: var(--mentra-safe-bottom); }" - : "" - // Console-tap shim: wrap console.log/warn/error/info/debug so each call // also forwards a `dev_log` envelope back to the phone via // ReactNativeWebView.postMessage. Dev mode forwards to the laptop's @@ -251,8 +233,7 @@ export function buildMiniappGlobalsScript(opts: BuildMiniappGlobalsOptions): str styleEl.setAttribute("data-mentra-injected", "1"); styleEl.textContent = ":root { ${cssVarsBlock} }" + - "html, body { touch-action: manipulation; -ms-content-zooming: none; }" + - "${bodySafeAreaBlock}"; + "html, body { touch-action: manipulation; -ms-content-zooming: none; }"; (document.head || document.documentElement).appendChild(styleEl); } catch (e) { /* ignore */ } diff --git a/mobile/src/components/miniapp/LocalMiniappView.tsx b/mobile/src/components/miniapp/LocalMiniappView.tsx index 5c36d75cc2..b99bea5fc7 100644 --- a/mobile/src/components/miniapp/LocalMiniappView.tsx +++ b/mobile/src/components/miniapp/LocalMiniappView.tsx @@ -480,11 +480,6 @@ function LocalMiniappView({ right: insets.right, }, webviewFillsStatusBar: true, - // The Compositor renders this WebView edge-to-edge (Screen preset="fixed", - // no bottom safeAreaEdge), so on Android with a 3-button nav bar the bottom - // inset must be applied as the miniapp's default body padding — otherwise - // its content draws behind the nav buttons. - webviewFillsNavBar: true, colorScheme, }) const uiShim = buildMentraUiShim({packageName}) From 50c7a9b14b4d3f8256feecc2b0fc70b47fd8513c Mon Sep 17 00:00:00 2001 From: yash Date: Mon, 29 Jun 2026 15:05:44 -0700 Subject: [PATCH 6/7] chore: update nimo ios implementation to be same as android --- .../bluetooth-sdk/ios/Source/sgcs/Nimo.swift | 419 +++++++++++++++--- 1 file changed, 356 insertions(+), 63 deletions(-) diff --git a/mobile/modules/bluetooth-sdk/ios/Source/sgcs/Nimo.swift b/mobile/modules/bluetooth-sdk/ios/Source/sgcs/Nimo.swift index a88d711c0f..a6ed9ef437 100644 --- a/mobile/modules/bluetooth-sdk/ios/Source/sgcs/Nimo.swift +++ b/mobile/modules/bluetooth-sdk/ios/Source/sgcs/Nimo.swift @@ -113,19 +113,31 @@ enum NimoProtocol { // widget resTypes static let WIDGET_TEXT_NEW = 0x00 + static let WIDGET_TEXT_APPEND = 0x01 + static let WIDGET_RAW = 0x02 static let WIDGET_PICTURE = 0x80 // navigation widget resIds (appId 0x01): mini map 0x00, arrow 0x01, turn text 0x02, // status bar 0x03 (raw), tip 0x04, large map 0x05. static let NAV_RES_MINI_MAP = 0x00 + static let NAV_RES_ARROW = 0x01 static let NAV_RES_TURN_TEXT = 0x02 + static let NAV_RES_INFO = 0x03 + static let NAV_RES_TIP = 0x04 static let NAV_RES_LARGE_MAP = 0x05 // navigation image widget sizes (hard firmware requirements; see IMAGE_PROTOCOL). static let NAV_MINI_MAP_SIZE = 160 + static let NAV_ARROW_SIZE = 48 static let NAV_LARGE_MAP_WIDTH = 452 static let NAV_LARGE_MAP_HEIGHT = 170 + // naviInfo status struct (resId 0x03, raw): navType(1) + dist(32 UTF-8) + time(32 UTF-8). + static let NAV_INFO_STRUCT_SIZE = 65 + static let NAV_TYPE_WALK = 0x00 + static let NAV_TYPE_CYCLE = 0x01 + static let NAV_TYPE_EBIKE = 0x02 + // phone types static let PHONE_TYPE_IOS = 0x01 @@ -582,6 +594,8 @@ class Nimo: NSObject, SGCManager { static let writeWatchdogSeconds: TimeInterval = 1 // Fall back to the other ear if the preferred one goes quiet for this long. static let micSideFallbackSeconds: TimeInterval = 2 + static let setTimeMaxAttempts = 3 + static let setTimeRetrySeconds: TimeInterval = 0.5 } // The text surface every sendTextWall renders into. The ASR note view (appId 0x04, @@ -627,6 +641,7 @@ class Nimo: NSObject, SGCManager { private var twsConnected = false private var twsTimeoutItem: DispatchWorkItem? private var pairingTimeoutItem: DispatchWorkItem? + private var setTimeAttempts = 0 // Pending acks keyed by (cmd << 8) | key private struct PendingAck { @@ -656,6 +671,10 @@ class Nimo: NSObject, SGCManager { private var textAppEntered = false private var navAppEntered = false private var currentGlassesAppId = -1 + // Last navigation HUD frame pushed, to coalesce the app's ~3s identical re-pushes. + private var lastNavHudKey = "" + // Arrow direction currently shown, so the icon is re-sent only when the turn changes. + private var lastNavArrow: NavArrow? // Battery private var lastBatteryLevel = -1 @@ -825,26 +844,38 @@ class Nimo: NSObject, SGCManager { _ text: String, x _: Int32, y _: Int32, width _: Int32, height _: Int32, borderWidth _: Int32, borderRadius _: Int32 ) async { - // Navigation pushes turn text via positioned_text. Nimo widgets have fixed geometry, so - // position/border are ignored — funnel the text through the same coalesced path as - // sendTextWall so it renders on the ASR note page. Without this override the base no-op - // silently dropped all navigation text. - // Bridge.log("NIMO: sendPositionedText(text=\(text))") - // pendingText = text + // The navigation app pushes its whole turn-by-turn HUD through positioned_text in one + // combined string (arrow / distance / instruction / ETA). Nimo widgets have fixed + // geometry, so x/y/border are ignored; instead we parse the string and fan the pieces + // out to the dedicated nav widgets (arrow icon, instruction, tip, status struct). + if handshakeState != .ready { return } + let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) + // Blank pushes (e.g. the app clearing its loading-message region) must not wipe the + // current HUD — ignore them and keep the last frame on screen. + if trimmed.isEmpty { return } + let hud = parseNavHud(trimmed) + // Coalesce: the app re-pushes the same frame every ~3s; only redraw on real changes. + let key = hud.key + if key == lastNavHudKey { return } + lastNavHudKey = key + Bridge.log( + "NIMO: nav HUD → arrow=\(hud.arrow) instr=\"\(hud.instruction)\" tip=\"\(hud.tip)\" eta=\"\(hud.eta)\"" + ) + enterNavAppIfNeeded() + pushNavHud(hud) } func displayBitmap( base64ImageData: String, x _: Int32?, y _: Int32?, width _: Int32?, height _: Int32? ) async -> Bool { // Nimo widgets have fixed geometry; x/y are not honored. Renders into the navigation - // large-map widget (appId 0x01, resId 0x05, 452x170 2bpp) — the full-width nav map, - // which shows far more than the small 160x160 mini-map (resId 0x00). bitmapToGrayscale - // aspect-fits onto black, so a non-matching source aspect letterboxes rather than - // distorts. The nav app must be foregrounded before pushing content (mirrors the text - // path). TODO: hardware-verify the large-map widget renders and the nav app-mode. - let targetWidth = NimoProtocol.NAV_LARGE_MAP_WIDTH - let targetHeight = NimoProtocol.NAV_LARGE_MAP_HEIGHT - Bridge.log("NIMO: displayBitmap → nav large map (navAppEntered=\(navAppEntered))") + // mini-map widget (appId 0x01, resId 0x00, 160x160 2bpp) — the compact "small screen" + // nav map. bitmapToGrayscale aspect-fits onto black, so a non-matching source aspect + // letterboxes rather than distorts. The nav app must be foregrounded before pushing + // content (mirrors the text path). TODO: hardware-verify the mini-map widget renders. + let targetWidth = NimoProtocol.NAV_MINI_MAP_SIZE + let targetHeight = NimoProtocol.NAV_MINI_MAP_SIZE + Bridge.log("NIMO: displayBitmap → nav mini map (navAppEntered=\(navAppEntered))") guard let imageData = Data(base64Encoded: base64ImageData), let image = UIImage(data: imageData) else { @@ -853,37 +884,266 @@ class Nimo: NSObject, SGCManager { } guard let grayscale = bitmapToGrayscale(image, width: targetWidth, height: targetHeight) else { return false } - let packed = packL8To2bpp(grayscale) - let (payload, compression) = compressAdaptive(packed) - if !navAppEntered { - sendFrame( - NimoFrameCodec.encodeFrame( - cmd: NimoProtocol.CMD_CONTROL_INSTRUCTION, - key: NimoProtocol.CTRL_ENTER_APP, - payload: Data([UInt8(NimoProtocol.APP_ID_NAV), UInt8(NimoProtocol.APP_MODE_STANDALONE)]) - ) + let content = buildNavImageContent(grayscale, width: targetWidth, height: targetHeight) + enterNavAppIfNeeded() + enqueueNavWidget(resId: NimoProtocol.NAV_RES_MINI_MAP, resType: NimoProtocol.WIDGET_PICTURE, content: content) + return true + } + + /// Foregrounds the nav app (appId 0x01) once; corrected by app-state reports. + private func enterNavAppIfNeeded() { + if navAppEntered { return } + sendFrame( + NimoFrameCodec.encodeFrame( + cmd: NimoProtocol.CMD_CONTROL_INSTRUCTION, + key: NimoProtocol.CTRL_ENTER_APP, + payload: Data([UInt8(NimoProtocol.APP_ID_NAV), UInt8(NimoProtocol.APP_MODE_STANDALONE)]) + ) + ) + navAppEntered = true + } + + // MARK: - Navigation HUD Parsing + + /// Turn direction for the arrow icon; none renders a blank icon (no arrow). + private enum NavArrow: String { + case left + case right + case straight + case none + } + + /// The parsed pieces of one navigation HUD frame. + private struct NavHud { + let arrow: NavArrow + let instruction: String + let tip: String + let distance: String + let eta: String + // Whether to show the status struct (dist/ETA bar). Off for welcome/arrival frames. + let showStatus: Bool + + // Coalesce key: identical frames must produce the same string (mirrors Kotlin toString()). + var key: String { + "\(arrow.rawValue)|\(instruction)|\(tip)|\(distance)|\(eta)|\(showStatus)" + } + } + + /// Parses the navigation app's combined HUD string into the pieces the Nimo nav widgets + /// want. The app crams the whole frame into one positioned_text push, e.g.: + /// + /// → ← directional arrow glyph (←/→/↑) + /// In 198 m ← distance-to-turn ("In X" / "Now" / "Arriving in X") + /// Turn right onto Octavia St + /// ← blank line + /// ⊙ 14 min ← trip ETA + /// + /// Non-turn frames (Welcome / Rerouting… / Go back / Starting… / You have arrived …) have no + /// arrow and no ⊙ line; their text falls through to NavHud.instruction. + /// + /// The instruction is kept short (≤3 words — "Turn right", "Welcome to Maps", "Arrived"); any + /// "onto " suffix is moved into the tip line next to the distance so the big line stays + /// a glanceable maneuver. + private func parseNavHud(_ text: String) -> NavHud { + var eta = "" + var content: [String] = [] + for raw in text.split(separator: "\n", omittingEmptySubsequences: false) { + let line = String(raw).trimmingCharacters(in: .whitespaces) + if line.isEmpty { continue } + // Drop the arrow GLYPH line: the app forces it to "↑" until ~10 m before the turn, + // so it reads "straight" for almost the whole leg. We derive the real direction + // from the maneuver instruction instead (see arrowForInstruction). + if line == "←" || line == "→" || line == "↑" || line == "↺" || line == "⇆" { + continue + } + if line.hasPrefix("⊙") { + eta = String(line.dropFirst()).trimmingCharacters(in: .whitespaces) + } else { + content.append(line) + } + } + // The distance-to-turn line (used as the tip) vs the instruction line. + let distLine = content.first { line in + let lower = line.lowercased() + return lower.hasPrefix("in ") || lower == "now" || lower.hasPrefix("arriving") + } ?? "" + let rawInstruction = content.filter { $0 != distLine }.joined(separator: " ") + let (instruction, road) = shortenInstruction(rawInstruction) + // Arrow direction from the maneuver text, so it points at the turn the whole approach. + let arrow = arrowForInstruction(instruction) + // Welcome/arrival frames (arrow == none) carry no live nav data — hide the status bar. + let showStatus = arrow != .none + // Tip = distance line + road name (e.g. "In 198 m on Octavia St"); welcome gets a prompt. + var tip = [distLine, road].filter { !$0.isEmpty }.joined(separator: " on ") + if instruction.hasPrefix("Welcome") { tip = "Choose a location on the map" } + // Numeric distance for the status struct: strip the "In " / "Arriving in " prefix. + var distance = distLine + if let range = distance.range( + of: "^(arriving\\s+in|in)\\s+", options: [.regularExpression, .caseInsensitive] + ) { + distance.removeSubrange(range) + } + distance = distance.trimmingCharacters(in: .whitespaces) + return NavHud( + arrow: arrow, instruction: instruction, tip: tip, distance: distance, eta: eta, + showStatus: showStatus + ) + } + + /// Turn direction inferred from the maneuver phrase ("Turn left" → left, else straight). + private func arrowForInstruction(_ instruction: String) -> NavArrow { + let lower = instruction.lowercased() + if lower.contains("welcome") || lower.contains("arrived") { return .none } + if lower.contains("left") { return .left } + if lower.contains("right") { return .right } + return .straight + } + + /// Reduces a raw instruction to a short maneuver phrase (≤3 words) and the road name it + /// referenced. "Turn right onto Octavia St" → ("Turn right", "Octavia St"); welcome/arrival + /// frames collapse to fixed short labels. + private func shortenInstruction(_ raw: String) -> (String, String) { + let trimmed = raw.trimmingCharacters(in: .whitespaces) + if trimmed.isEmpty { return ("", "") } + let lower = trimmed.lowercased() + if lower.contains("welcome") { return ("Welcome to Maps", "") } + if lower.contains("arrived") { return ("Arrived!", "") } + // Peel off the "onto " suffix (or a leading "Onto ") into the road name. + var maneuver = trimmed + var road = "" + if let ontoRange = trimmed.range(of: " onto ", options: .caseInsensitive) { + maneuver = String(trimmed[trimmed.startIndex ..< ontoRange.lowerBound]) + .trimmingCharacters(in: .whitespaces) + road = String(trimmed[ontoRange.upperBound...]).trimmingCharacters(in: .whitespaces) + } else if lower.hasPrefix("onto ") { + road = String(trimmed.dropFirst(5)).trimmingCharacters(in: .whitespaces) + maneuver = "" + } + let capped = maneuver.split(whereSeparator: { $0 == " " || $0 == "\t" }) + .prefix(3).joined(separator: " ") + return (capped, road) + } + + /// Pushes a parsed HUD frame into the nav widgets (arrow icon / instruction / tip / info). + private func pushNavHud(_ hud: NavHud) { + // naviInstruction (0x02): primary turn text. + enqueueNavWidget( + resId: NimoProtocol.NAV_RES_TURN_TEXT, + resType: NimoProtocol.WIDGET_TEXT_NEW, + content: Data((hud.instruction.isEmpty ? " " : hud.instruction).utf8) + ) + // naviInstructionTip (0x04): distance-to-turn line. + enqueueNavWidget( + resId: NimoProtocol.NAV_RES_TIP, + resType: NimoProtocol.WIDGET_TEXT_NEW, + content: Data((hud.tip.isEmpty ? " " : hud.tip).utf8) + ) + // naviInfo (0x03): 65-byte status struct — navType(1) + dist(32) + time(32). Skipped on + // welcome/arrival frames so no (empty) status bar shows there. + if hud.showStatus { + var info = Data(count: NimoProtocol.NAV_INFO_STRUCT_SIZE) + info[0] = UInt8(NimoProtocol.NAV_TYPE_WALK) + writeUtf8Padded(&info, offset: 1, text: hud.distance, maxLen: 32) + writeUtf8Padded(&info, offset: 33, text: hud.eta, maxLen: 32) + enqueueNavWidget(resId: NimoProtocol.NAV_RES_INFO, resType: NimoProtocol.WIDGET_RAW, content: info) + } + // naviIcon (0x01): turn-arrow. Only re-send when the DIRECTION changes — re-pushing the + // identical image on every distance tick made the arrow flicker/"move". The bitmap for + // each direction is rendered once and cached. + if hud.arrow != lastNavArrow { + lastNavArrow = hud.arrow + enqueueNavWidget( + resId: NimoProtocol.NAV_RES_ARROW, + resType: NimoProtocol.WIDGET_PICTURE, + content: navArrowContent(hud.arrow) + ) + } + } + + // Arrow widget content for every direction, rendered ONCE on first use and reused — the + // bitmaps are deterministic, so the send path just selects the pre-built bytes instead of + // re-rendering on each turn. + private lazy var navArrowCache: [NavArrow: Data] = { + var cache: [NavArrow: Data] = [:] + for arrow in [NavArrow.left, .right, .straight, .none] { + guard let bmp = buildNavArrowBitmap(arrow), + let gray = bitmapToGrayscale( + bmp, width: NimoProtocol.NAV_ARROW_SIZE, height: NimoProtocol.NAV_ARROW_SIZE + ) + else { continue } + cache[arrow] = buildNavImageContent( + gray, width: NimoProtocol.NAV_ARROW_SIZE, height: NimoProtocol.NAV_ARROW_SIZE + ) + } + return cache + }() + + private func navArrowContent(_ arrow: NavArrow) -> Data { + return navArrowCache[arrow] ?? Data() + } + + /// A 48x48 white turn-arrow on black, rotated to point left/right/up per `arrow`. + private func buildNavArrowBitmap(_ arrow: NavArrow) -> UIImage? { + let size = NimoProtocol.NAV_ARROW_SIZE + let s = CGFloat(size) + let renderer = UIGraphicsImageRenderer(size: CGSize(width: s, height: s)) + return renderer.image { ctx in + let cg = ctx.cgContext + cg.setFillColor(UIColor.black.cgColor) + cg.fill(CGRect(x: 0, y: 0, width: s, height: s)) + // none → leave the icon blank (all black = nothing lit) for welcome/arrival frames. + if arrow == .none { return } + // Rotate the (upward) arrow around the center to face the turn direction. + let rotation: CGFloat + switch arrow { + case .left: rotation = -.pi / 2 + case .right: rotation = .pi / 2 + default: rotation = 0 + } + cg.translateBy(x: s / 2, y: s / 2) + cg.rotate(by: rotation) + cg.translateBy(x: -s / 2, y: -s / 2) + let path = UIBezierPath() + path.move(to: CGPoint(x: s * 0.5, y: s * 0.15)) // tip + path.addLine(to: CGPoint(x: s * 0.85, y: s * 0.55)) // head bottom-right + path.addLine(to: CGPoint(x: s * 0.62, y: s * 0.55)) + path.addLine(to: CGPoint(x: s * 0.62, y: s * 0.85)) // shaft bottom-right + path.addLine(to: CGPoint(x: s * 0.38, y: s * 0.85)) // shaft bottom-left + path.addLine(to: CGPoint(x: s * 0.38, y: s * 0.55)) + path.addLine(to: CGPoint(x: s * 0.15, y: s * 0.55)) // head bottom-left + path.close() + UIColor.white.setFill() + path.fill() + } + } + + // MARK: - Nav Widget Helpers + + /// Enqueues a single nav-app content widget (chunked, appId 0x01, layout 0). + private func enqueueNavWidget(resId: Int, resType: Int, content: Data) { + enqueueFrames( + NimoFrameCodec.updateContentFrames( + appId: NimoProtocol.APP_ID_NAV, + layoutId: 0, + resId: resId, + resType: resType, + content: content ) - // Optimistic; corrected by app-state reports if the glasses refuse/exit. - navAppEntered = true - } - let content = - NimoFrameCodec.imageHeader( - width: targetWidth, - height: targetHeight, - formatBpp: NimoProtocol.FORMAT_2BPP, - compression: compression, - originalSize: packed.count, - compressedSize: compression == NimoProtocol.COMPRESSION_NONE ? 0 : payload.count - ) + payload - let frames = NimoFrameCodec.updateContentFrames( - appId: NimoProtocol.APP_ID_NAV, - layoutId: 0, - resId: NimoProtocol.NAV_RES_LARGE_MAP, - resType: NimoProtocol.WIDGET_PICTURE, - content: content ) - enqueueFrames(frames) - return true + } + + /// Builds the [imageHeader + payload] blob for a nav image widget from an L8 buffer. + private func buildNavImageContent(_ grayscale: Data, width: Int, height: Int) -> Data { + let packed = packL8To2bpp(grayscale) + let (payload, compression) = compressAdaptive(packed) + return NimoFrameCodec.imageHeader( + width: width, + height: height, + formatBpp: NimoProtocol.FORMAT_2BPP, + compression: compression, + originalSize: packed.count, + compressedSize: compression == NimoProtocol.COMPRESSION_NONE ? 0 : payload.count + ) + payload } func showDashboard() { @@ -922,22 +1182,15 @@ class Nimo: NSObject, SGCManager { // MARK: - SGCManager: Device Control func setHeadUpAngle(_ angle: Int) { - let clamped = max(0, min(90, angle)) - Bridge.log("NIMO: setHeadUpAngle(\(clamped))") - // Enable the head-up display gesture, then set the wake angle. + Bridge.log("NIMO: setHeadUpAngle(\(angle)) — head-up display disabled for now") + // Head-up display is intentionally kept OFF for now (it auto-wakes the dashboard and the + // wake-angle semantics aren't dialed in). Force it disabled regardless of the requested + // angle. TODO: restore the enable + setAngle [optType, deg] path once verified. sendFrame( NimoFrameCodec.encodeFrame( cmd: NimoProtocol.CMD_SET_PARAMETER, key: NimoProtocol.SET_HEADUP_DISPLAY, - payload: Data([1]) - ) - ) - // setAngle payload is [optType, deg]. TODO: hardware-verify optType semantics. - sendFrame( - NimoFrameCodec.encodeFrame( - cmd: NimoProtocol.CMD_SET_PARAMETER, - key: NimoProtocol.SET_ANGLE, - payload: Data([0x01, UInt8(clamped)]) + payload: Data([0]) ) ) } @@ -1278,17 +1531,33 @@ class Nimo: NSObject, SGCManager { cancelTwsTimeout() handshakeState = .awaitingTimeAck Bridge.log("NIMO: TWS OK — sending setTime (awaiting ACK)") + setTimeAttempts = 0 + attemptSetTime() + } + + private func attemptSetTime() { + guard handshakeState == .awaitingTimeAck, peripheral != nil else { return } + setTimeAttempts += 1 sendAwaitingAck( cmd: NimoProtocol.CMD_SET_PARAMETER, key: NimoProtocol.SET_TIME, payload: NimoFrameCodec.encodeDeviceTime() ) { [weak self] ok in guard let self else { return } - if !ok { - Bridge.log("NIMO: setTime ACK failed/timed out — handshake failed") - self.handshakeFailed() - } else { + if ok { self.finishHandshake() + } else if self.setTimeAttempts < Const.setTimeMaxAttempts, + self.handshakeState == .awaitingTimeAck, + self.peripheral != nil { + // The firmware can answer "busy" right after the link comes up — + // give it a moment and retry instead of dropping the connection. + Bridge.log("NIMO: setTime attempt \(self.setTimeAttempts) failed — retrying") + DispatchQueue.main.asyncAfter(deadline: .now() + Const.setTimeRetrySeconds) { + [weak self] in self?.attemptSetTime() + } + } else { + Bridge.log("NIMO: setTime failed after \(self.setTimeAttempts) attempts — handshake failed") + self.handshakeFailed() } } } @@ -1304,6 +1573,16 @@ class Nimo: NSObject, SGCManager { needsAck: false ) ) + // Disable the on-device head-up gesture so raising your head doesn't auto-wake the + // dashboard. Disabled for now; re-enable once the wake-angle behaviour is dialed in. + sendFrame( + NimoFrameCodec.encodeFrame( + cmd: NimoProtocol.CMD_SET_PARAMETER, + key: NimoProtocol.SET_HEADUP_DISPLAY, + payload: Data([0]), + needsAck: false + ) + ) getBatteryStatus() requestVersionInfo() @@ -1329,6 +1608,8 @@ class Nimo: NSObject, SGCManager { textAppEntered = false navAppEntered = false currentGlassesAppId = -1 + lastNavHudKey = "" + lastNavArrow = nil pendingText = nil receiveAssembler.reset() writeQueue.removeAll() @@ -1451,11 +1732,20 @@ class Nimo: NSObject, SGCManager { case NimoProtocol.STATE_ENTER: currentGlassesAppId = appId if appId != textAppId { textAppEntered = false } - if appId != NimoProtocol.APP_ID_NAV { navAppEntered = false } + if appId != NimoProtocol.APP_ID_NAV { + navAppEntered = false + // Force a full HUD repaint next time the nav app reopens. + lastNavHudKey = "" + lastNavArrow = nil + } case NimoProtocol.STATE_EXIT: if appId == currentGlassesAppId { currentGlassesAppId = -1 } if appId == textAppId { textAppEntered = false } - if appId == NimoProtocol.APP_ID_NAV { navAppEntered = false } + if appId == NimoProtocol.APP_ID_NAV { + navAppEntered = false + lastNavHudKey = "" + lastNavArrow = nil + } default: break } @@ -1511,10 +1801,13 @@ class Nimo: NSObject, SGCManager { switch code { case NimoProtocol.INPUT_HEAD_UP: DeviceStore.shared.apply("glasses", "headUp", true) - Bridge.sendHeadUp(true) + // Head-up forwarding to the cloud is disabled for now (the wake-angle + // semantics aren't dialed in yet). Keep the local state; don't notify. + // Bridge.sendHeadUp(true) case NimoProtocol.INPUT_HEAD_DOWN: DeviceStore.shared.apply("glasses", "headUp", false) - Bridge.sendHeadUp(false) + // Head-up forwarding to the cloud is disabled for now (see INPUT_HEAD_UP). + // Bridge.sendHeadUp(false) case NimoProtocol.INPUT_CLICK_RIGHT, NimoProtocol.INPUT_CLICK_LEFT: Bridge.sendTouchEvent( deviceModel: DeviceTypes.NIMO, gestureName: "single_tap", timestamp: timestamp From 9e2fcde46ed8d96a616daedd3b81c936b4b0f7cc Mon Sep 17 00:00:00 2001 From: yash Date: Tue, 30 Jun 2026 20:30:20 -0700 Subject: [PATCH 7/7] chore: fix nimo for ios --- .../bluetooth-sdk/ios/Source/sgcs/Nimo.swift | 352 ++++++++++++++---- mobile/src/app/pairing/btclassic.tsx | 58 ++- mobile/src/app/pairing/scan.tsx | 2 +- 3 files changed, 337 insertions(+), 75 deletions(-) diff --git a/mobile/modules/bluetooth-sdk/ios/Source/sgcs/Nimo.swift b/mobile/modules/bluetooth-sdk/ios/Source/sgcs/Nimo.swift index a6ed9ef437..c14c2b6404 100644 --- a/mobile/modules/bluetooth-sdk/ios/Source/sgcs/Nimo.swift +++ b/mobile/modules/bluetooth-sdk/ios/Source/sgcs/Nimo.swift @@ -6,6 +6,7 @@ // separate H-T-L-V Opus microphone channel (char 2025). // +import AudioToolbox import AVFoundation import Compression import CoreBluetooth @@ -469,78 +470,207 @@ enum NimoAudioParser { } } -// MARK: - Opus Decoder (AVAudioConverter) +// MARK: - Opus Decoder (AudioToolbox) + +/// Holds the single Opus packet being fed to the AudioConverter for its C input +/// callback. Uses a manually-allocated buffer + packet-description pointer that +/// stay valid AFTER the callback returns (the converter reads them out of band), +/// which a scoped `withUnsafeBytes` pointer would not. +private final class NimoOpusInputContext { + var buffer: UnsafeMutableRawPointer? + var byteSize: Int = 0 + let packetDescPtr = UnsafeMutablePointer.allocate(capacity: 1) + var consumed = false + + func set(_ data: Data) { + buffer?.deallocate() + byteSize = data.count + let buf = UnsafeMutableRawPointer.allocate(byteCount: max(1, byteSize), alignment: 1) + data.copyBytes(to: buf.assumingMemoryBound(to: UInt8.self), count: byteSize) + buffer = buf + packetDescPtr.pointee = AudioStreamPacketDescription( + mStartOffset: 0, mVariableFramesInPacket: 0, mDataByteSize: UInt32(byteSize) + ) + consumed = false + } + + deinit { + buffer?.deallocate() + packetDescPtr.deallocate() + } +} + +/// Returned by the input proc once the single cached packet is exhausted. Signalling +/// "no more data" with a NON-zero status (instead of 0 packets + noErr) is critical: +/// 0-packets-noErr latches AudioConverter into a permanent end-of-stream state, which +/// makes it decode the first packet and then return 0 frames for every packet after. +private let kNimoOpusNoMoreData: OSStatus = 0x6E6F_4D44 // 'noMD' + +/// AudioConverter input callback: hand over the one cached Opus packet exactly once, +/// then report "no more data" so the converter drains and returns what it decoded. +private func nimoOpusInputProc( + _: AudioConverterRef, + _ ioNumberDataPackets: UnsafeMutablePointer, + _ ioData: UnsafeMutablePointer, + _ outDataPacketDescription: UnsafeMutablePointer?>?, + _ inUserData: UnsafeMutableRawPointer? +) -> OSStatus { + guard let inUserData else { + ioNumberDataPackets.pointee = 0 + return kNimoOpusNoMoreData + } + let ctx = Unmanaged.fromOpaque(inUserData).takeUnretainedValue() + if ctx.consumed || ctx.buffer == nil { + ioNumberDataPackets.pointee = 0 + return kNimoOpusNoMoreData + } + ctx.consumed = true + ioData.pointee.mNumberBuffers = 1 + ioData.pointee.mBuffers.mNumberChannels = 1 + ioData.pointee.mBuffers.mDataByteSize = UInt32(ctx.byteSize) + ioData.pointee.mBuffers.mData = ctx.buffer + ioNumberDataPackets.pointee = 1 + outDataPacketDescription?.pointee = ctx.packetDescPtr + return noErr +} -/// Decodes the glasses' 16 kHz mono Opus frames to 16-bit PCM via the system -/// Opus decoder, so no native Opus library has to be vendored. -private class NimoOpusDecoder { - private let opusFormat: AVAudioFormat - private let pcmFormat: AVAudioFormat - private var converter: AVAudioConverter? +/// Decodes the glasses' mono Opus frames to 16 kHz 16-bit PCM via the system Opus +/// decoder (AudioToolbox), so no native Opus library has to be vendored. +/// +/// Uses the low-level AudioConverter (not AVAudioConverter) specifically so the +/// OpusHead magic cookie can be set — Apple's Opus decoder needs it to initialize, +/// and AVAudioConverter has no way to provide it, so the AVAudioConverter path +/// rejected every packet with an AudioCodec bad-data error. Decodes at Opus' native +/// 48 kHz (the only rate the decoder supports) and downsamples to 16 kHz, mirroring +/// the Android MediaCodec path. +private final class NimoOpusDecoder { + private var converter: AudioConverterRef? + private let input = NimoOpusInputContext() + private var errorLogCount = 0 + private var zeroFrameCount = 0 + private var emittedFirst = false init?() { - var desc = AudioStreamBasicDescription( - mSampleRate: 16000, - mFormatID: kAudioFormatOpus, - mFormatFlags: 0, - mBytesPerPacket: 0, - mFramesPerPacket: 320, // 20 ms at 16 kHz - mBytesPerFrame: 0, - mChannelsPerFrame: 1, - mBitsPerChannel: 0, - mReserved: 0 + var inASBD = AudioStreamBasicDescription( + mSampleRate: 48000, mFormatID: kAudioFormatOpus, mFormatFlags: 0, + mBytesPerPacket: 0, mFramesPerPacket: 960, mBytesPerFrame: 0, + mChannelsPerFrame: 1, mBitsPerChannel: 0, mReserved: 0 ) - guard let inFormat = AVAudioFormat(streamDescription: &desc), - let outFormat = AVAudioFormat( - commonFormat: .pcmFormatInt16, sampleRate: 16000, channels: 1, interleaved: true - ) - else { return nil } - opusFormat = inFormat - pcmFormat = outFormat - converter = AVAudioConverter(from: inFormat, to: outFormat) - if converter == nil { + var outASBD = AudioStreamBasicDescription( + mSampleRate: 48000, mFormatID: kAudioFormatLinearPCM, + mFormatFlags: kAudioFormatFlagIsSignedInteger | kAudioFormatFlagIsPacked, + mBytesPerPacket: 2, mFramesPerPacket: 1, mBytesPerFrame: 2, + mChannelsPerFrame: 1, mBitsPerChannel: 16, mReserved: 0 + ) + + var conv: AudioConverterRef? + let status = AudioConverterNew(&inASBD, &outASBD, &conv) + guard status == noErr, let conv else { + Bridge.log("NIMO: AudioConverterNew(Opus) failed: \(status)") return nil } + converter = conv + + // OpusHead identification header (19 bytes) — the same structure the Android + // MediaCodec path passes as csd-0. Set as the decompression magic cookie so + // the Opus decoder can initialize. + var cookie: [UInt8] = Array("OpusHead".utf8) + cookie.append(1) // version + cookie.append(1) // channel count (mono) + cookie.append(contentsOf: [0, 0]) // pre-skip (LE) + let rate: UInt32 = 48000 // input sample rate (LE), matches the ASBD + cookie.append(UInt8(rate & 0xFF)) + cookie.append(UInt8((rate >> 8) & 0xFF)) + cookie.append(UInt8((rate >> 16) & 0xFF)) + cookie.append(UInt8((rate >> 24) & 0xFF)) + cookie.append(contentsOf: [0, 0]) // output gain (LE) + cookie.append(0) // channel mapping family + let cookieStatus = AudioConverterSetProperty( + conv, kAudioConverterDecompressionMagicCookie, UInt32(cookie.count), cookie + ) + if cookieStatus != noErr { + Bridge.log("NIMO: set Opus magic cookie failed: \(cookieStatus)") + } + Bridge.log("NIMO: Opus decoder ready (AudioToolbox @48k, OpusHead cookie)") + } + + deinit { + if let converter { AudioConverterDispose(converter) } } func decode(_ opusFrame: Data) -> Data? { guard let converter, !opusFrame.isEmpty else { return nil } - - let compressed = AVAudioCompressedBuffer( - format: opusFormat, packetCapacity: 1, maximumPacketSize: opusFrame.count - ) - opusFrame.withUnsafeBytes { (raw: UnsafeRawBufferPointer) in - compressed.data.copyMemory(from: raw.baseAddress!, byteCount: opusFrame.count) + input.set(opusFrame) + + let maxFrames: UInt32 = 2880 // up to 60 ms @ 48 kHz + var outSamples = [Int16](repeating: 0, count: Int(maxFrames)) + var producedFrames = maxFrames + var status: OSStatus = noErr + + outSamples.withUnsafeMutableBytes { rawOut in + var bufferList = AudioBufferList( + mNumberBuffers: 1, + mBuffers: AudioBuffer( + mNumberChannels: 1, + mDataByteSize: UInt32(rawOut.count), + mData: rawOut.baseAddress + ) + ) + let ctx = Unmanaged.passUnretained(input).toOpaque() + status = AudioConverterFillComplexBuffer( + converter, nimoOpusInputProc, ctx, &producedFrames, &bufferList, nil + ) } - compressed.byteLength = UInt32(opusFrame.count) - compressed.packetCount = 1 - compressed.packetDescriptions?.pointee = AudioStreamPacketDescription( - mStartOffset: 0, mVariableFramesInPacket: 0, mDataByteSize: UInt32(opusFrame.count) - ) - // Up to 40 ms of output per frame, to be safe. - guard let pcmBuffer = AVAudioPCMBuffer(pcmFormat: pcmFormat, frameCapacity: 640) else { - return nil - } - var fed = false - var error: NSError? - let status = converter.convert(to: pcmBuffer, error: &error) { _, outStatus in - if fed { - outStatus.pointee = .noDataNow - return nil + // Our input proc returns kNimoOpusNoMoreData once the packet is consumed; that + // is the normal terminator, not a failure. Anything else with no output is real. + if producedFrames == 0 { + if status != noErr, status != kNimoOpusNoMoreData { + errorLogCount += 1 + if errorLogCount == 1 || errorLogCount % 100 == 0 { + Bridge.log("NIMO: Opus decode error: \(status) (count=\(errorLogCount))") + } + } else { + zeroFrameCount += 1 + if zeroFrameCount == 1 || zeroFrameCount % 100 == 0 { + Bridge.log("NIMO: Opus decode produced 0 frames (status=\(status), count=\(zeroFrameCount))") + } } - fed = true - outStatus.pointee = .haveData - return compressed - } - if status == .error { - Bridge.log("NIMO: Opus decode error: \(error?.localizedDescription ?? "unknown")") return nil } - guard pcmBuffer.frameLength > 0, let channel = pcmBuffer.int16ChannelData else { - return nil + + let count = Int(producedFrames) + let pcm48 = Array(outSamples.prefix(count)).withUnsafeBufferPointer { Data(buffer: $0) } + // The MentraOS pipeline expects 16 kHz mono → decimate 48 kHz by 3. + let pcm16 = downsample48kTo16k(pcm48) + if !emittedFirst { + emittedFirst = true + Bridge.log( + "NIMO: first Opus PCM decoded (\(count) frames @48k → \(pcm16.count) bytes @16k)" + ) + } + return pcm16 + } + + /// 48 kHz → 16 kHz: average each group of 3 Int16 samples (cheap low-pass + + /// decimate). Matches the Android decoder's downsample48kTo16k. + private func downsample48kTo16k(_ pcm48: Data) -> Data { + let inSamples = pcm48.count / 2 + let outSamples = inSamples / 3 + if outSamples == 0 { return Data() } + var out = Data(count: outSamples * 2) + pcm48.withUnsafeBytes { (src: UnsafeRawBufferPointer) in + let s = src.bindMemory(to: Int16.self) + out.withUnsafeMutableBytes { (dst: UnsafeMutableRawBufferPointer) in + let d = dst.bindMemory(to: Int16.self) + for o in 0 ..< outSamples { + let i = o * 3 + let sum = Int(s[i]) + Int(s[i + 1]) + Int(s[i + 2]) + d[o] = Int16(max(-32768, min(32767, sum / 3))) + } + } } - return Data(bytes: channel[0], count: Int(pcmBuffer.frameLength) * 2) + return out } } @@ -1365,6 +1495,19 @@ class Nimo: NSObject, SGCManager { return true } + // The main Nimo device does NOT advertise its name (or itself) over BLE — + // only the "_ble" ANCS side channel does. On Android the vendor + // discovers it over CLASSIC (BR/EDR) inquiry; iOS can't do classic + // discovery, so instead the glasses are bonded over classic in the iOS + // Settings app, which iOS keeps connected at the system level. + // retrieveConnectedPeripherals surfaces that system-connected device here — + // the primary iOS discovery path (mirrors the vendor's getConnectedDevice(7033)). + checkSystemConnectedDevices() + + // Scan unfiltered and identify the Nimo by name OR advertised service UUID + // (see didDiscover). Filtering the scan by [7033] would silently miss the + // device if it exposes the UART service only in GATT and not in its LE + // advertising packet — so we scan everything and inspect each advert. centralManager!.scanForPeripherals( withServices: nil, options: [CBCentralManagerScanOptionAllowDuplicatesKey: false] @@ -1372,6 +1515,56 @@ class Nimo: NSObject, SGCManager { return true } + /// Surfaces (and auto-connects) Nimo glasses already connected/paired at the + /// iOS system level. The device is bonded over classic BT in Settings and does + /// not re-advertise its name over LE, so a plain scan can't find it — but + /// retrieveConnectedPeripherals(withServices:) returns it with a resolved name. + private func checkSystemConnectedDevices() { + guard let centralManager else { return } + let connected = centralManager.retrieveConnectedPeripherals( + withServices: [NimoBLE.SERVICE_UUID] + ) + Bridge.log( + "NIMO: retrieveConnectedPeripherals([7033]) → \(connected.count) device(s); " + + "target=\(DEVICE_SEARCH_ID)" + ) + for peripheral in connected { + let name = peripheral.name ?? "" + Bridge.log("NIMO: system-connected candidate \(name) (\(peripheral.identifier))") + // System-connected peripherals carry a resolved name; if one is missing + // it can't be name-matched, so connect directly during a targeted connect. + if let realName = peripheral.name { + handleDiscoveredPeripheral(peripheral, name: realName, rssi: 0) + } else if DEVICE_SEARCH_ID != "NOT_SET", self.peripheral == nil { + stopScan() + self.peripheral = peripheral + peripheral.delegate = self + centralManager.connect(peripheral, options: nil) + } + } + } + + /// Shared handling for a candidate Nimo peripheral from either the active scan + /// or the system-connected list: report it to the UI, and auto-connect when it + /// matches the requested device id. Must run on the main queue. + private func handleDiscoveredPeripheral(_ peripheral: CBPeripheral, name: String, rssi: Int) { + guard isNimoMainDevice(name) else { return } + + Bridge.sendDiscoveredDevice(DeviceTypes.NIMO, name, rssi: rssi) + + guard DEVICE_SEARCH_ID != "NOT_SET" else { return } + guard name == DEVICE_SEARCH_ID else { return } + guard self.peripheral == nil else { return } + + Bridge.log("NIMO: Connecting to \(name)") + stopScan() + lastDeviceName = name + lastDeviceUUID = peripheral.identifier.uuidString + self.peripheral = peripheral + peripheral.delegate = self + centralManager?.connect(peripheral, options: nil) + } + private func connectByUUID() -> Bool { guard DEVICE_SEARCH_ID != "NOT_SET", !DEVICE_SEARCH_ID.isEmpty else { return false } guard lastDeviceName == DEVICE_SEARCH_ID, @@ -2058,29 +2251,41 @@ extension Nimo: CBCentralManagerDelegate { advertisementData: [String: Any], rssi: NSNumber ) { - guard - let name = peripheral.name - ?? advertisementData[CBAdvertisementDataLocalNameKey] as? String - else { return } + // The main device advertises service 7033 but no local name; fall back to + // the peripheral's (system-resolved) name, which may still be nil here. + let name = peripheral.name + ?? advertisementData[CBAdvertisementDataLocalNameKey] as? String + let advServices = + advertisementData[CBAdvertisementDataServiceUUIDsKey] as? [CBUUID] ?? [] + let advertisesUart = advServices.contains(NimoBLE.SERVICE_UUID) let rssiValue = rssi.intValue DispatchQueue.main.async { [weak self] in guard let self else { return } - guard self.isNimoMainDevice(name) else { return } - - Bridge.sendDiscoveredDevice(DeviceTypes.NIMO, name, rssi: rssiValue) - - guard self.DEVICE_SEARCH_ID != "NOT_SET" else { return } - guard name == self.DEVICE_SEARCH_ID else { return } - guard self.peripheral == nil else { return } + let nameMatch = name.map(self.isNimoMainDevice) ?? false + // Only log plausible candidates to avoid flooding the log with every + // nearby BLE device. + if nameMatch || advertisesUart { + Bridge.log( + "NIMO: discovered name=\(name ?? "") uartAdv=\(advertisesUart) " + + "services=\(advServices.map { $0.uuidString })" + ) + } - Bridge.log("NIMO: Connecting to \(name)") + if let name, nameMatch { + self.handleDiscoveredPeripheral(peripheral, name: name, rssi: rssiValue) + return + } + // Nameless but advertises the Nimo-exclusive UART service → it IS a Nimo + // main device that just doesn't broadcast a name over LE. During a + // targeted connect, connect to resolve the name (saved in didConnect). + guard advertisesUart, self.DEVICE_SEARCH_ID != "NOT_SET", self.peripheral == nil + else { return } + Bridge.log("NIMO: nameless UART-service match — connecting to resolve name") self.stopScan() - self.lastDeviceName = name - self.lastDeviceUUID = peripheral.identifier.uuidString self.peripheral = peripheral peripheral.delegate = self - central.connect(peripheral, options: nil) + self.centralManager?.connect(peripheral, options: nil) } } @@ -2088,6 +2293,11 @@ extension Nimo: CBCentralManagerDelegate { DispatchQueue.main.async { [weak self] in guard let self else { return } Bridge.log("NIMO: Connected to \(peripheral.name ?? "unknown")") + // Persist the now-resolved name so the next launch can UUID-fast-path, + // and so a connection made via the nameless service match is remembered. + if let name = peripheral.name { + self.lastDeviceName = name + } self.lastDeviceUUID = peripheral.identifier.uuidString peripheral.discoverServices([NimoBLE.SERVICE_UUID]) } diff --git a/mobile/src/app/pairing/btclassic.tsx b/mobile/src/app/pairing/btclassic.tsx index a6f4fb689f..c8a4c5fc45 100644 --- a/mobile/src/app/pairing/btclassic.tsx +++ b/mobile/src/app/pairing/btclassic.tsx @@ -1,10 +1,11 @@ -import {useEffect} from "react" +import {useEffect, useRef} from "react" import {Button, Screen} from "@/components/ignite" import {OnboardingGuide, OnboardingStep} from "@/components/onboarding/OnboardingGuide" import {translate} from "@/i18n" import {focusEffectPreventBack, usePushPrevious} from "@/contexts/NavigationHistoryContext" -import {useGlassesStore} from "@/stores/glasses" -import BluetoothSdk from "@mentra/bluetooth-sdk" +import {selectGlassesConnected, useGlassesStore} from "@/stores/glasses" +import {DeviceTypes} from "@/../../cloud/packages/types/src" +import BluetoothSdk, {type DeviceModel} from "@mentra/bluetooth-sdk" import {SETTINGS, useSetting} from "@/stores/settings" import {SettingsNavigationUtils} from "@/utils/SettingsNavigationUtils" import {useCoreStore} from "@/stores/core" @@ -17,10 +18,21 @@ export default function BtClassicPairingScreen() { const {goBack} = useNavigationStore.getState() const pushPrevious = usePushPrevious() const bluetoothClassicConnected = useGlassesStore((state) => state.bluetoothClassicConnected) + const glassesConnected = useGlassesStore(selectGlassesConnected) const otherBtConnected = useCoreStore((state) => state.otherBtConnected) + const searchResults = useCoreStore((state) => state.searchResults) const [deviceName] = useSetting(SETTINGS.device_name.key) + const [defaultWearable] = useSetting(SETTINGS.default_wearable.key) const {theme} = useAppTheme() + // Nimo has no classic AUDIO profile, so `bluetoothClassicConnected` never flips + // (that flag is set from iOS audio-session detection). Instead we drive the whole + // flow off the real BLE connection: keep re-scanning until the glasses appear as + // system-connected after the user pairs in Settings, then connect and complete. + const isNimo = defaultWearable === DeviceTypes.NIMO + const nimoConnectTriggeredRef = useRef(false) + const nimoAdvancedRef = useRef(false) + focusEffectPreventBack() const handleSuccess = () => { @@ -57,6 +69,46 @@ export default function BtClassicPairingScreen() { } }, [deviceName]) + // Nimo: poll a non-destructive scan (startScan does NOT arm the connect-time + // pairing timeout the way connect does) so the glasses can be detected as + // system-connected as soon as the user finishes pairing in Settings. + useEffect(() => { + if (!isNimo || !defaultWearable) return + if (glassesConnected) return + let cancelled = false + const scanOnce = () => { + BluetoothSdk.startScan(defaultWearable as DeviceModel).catch(() => undefined) + } + scanOnce() + const interval = setInterval(() => { + if (!cancelled) scanOnce() + }, 3000) + return () => { + cancelled = true + clearInterval(interval) + } + }, [isNimo, defaultWearable, glassesConnected]) + + // Nimo: once our glasses show up in the scan results (system-connected after the + // Settings pairing), connect once. They're already present, so this connects fast. + useEffect(() => { + if (!isNimo || nimoConnectTriggeredRef.current) return + if (!searchResults.some((d) => d.name === deviceName)) return + nimoConnectTriggeredRef.current = true + BluetoothSdk.connectDefault().catch((error) => { + console.error("Failed to connect Nimo after Bluetooth Classic pairing:", error) + }) + }, [isNimo, searchResults, deviceName]) + + // Nimo: complete on the real glasses connection (not the audio-only classic flag). + // Hand back to the loading screen underneath, which advances to success. + useEffect(() => { + if (!isNimo || nimoAdvancedRef.current) return + if (!glassesConnected) return + nimoAdvancedRef.current = true + pushPrevious() + }, [isNimo, glassesConnected]) + let steps: OnboardingStep[] = [ { type: "image", diff --git a/mobile/src/app/pairing/scan.tsx b/mobile/src/app/pairing/scan.tsx index 233b695e4a..6497092d94 100644 --- a/mobile/src/app/pairing/scan.tsx +++ b/mobile/src/app/pairing/scan.tsx @@ -98,7 +98,7 @@ export default function SelectGlassesBluetoothScreen() { } const startPairing = async (device: Device) => { - const deviceTypesWithBtClassic = [DeviceTypes.LIVE] + const deviceTypesWithBtClassic = [DeviceTypes.LIVE, DeviceTypes.NIMO] if (Platform.OS === "android" || bluetoothClassicConnected || !deviceTypesWithBtClassic.includes(device.model as DeviceTypes)) { setTimeout(() => { BluetoothSdk.connect(device).catch((error) => {