@mentra/bluetooth-sdk 3.3.0-dev.517 → 3.3.0-dev.523
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/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkModule.kt +2 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/DeviceManager.kt +55 -10
- package/android/src/main/java/com/mentra/bluetoothsdk/GeneratedReleaseMetadata.kt +5 -5
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/Nimo.kt +63 -6
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/NimoCanvasCoordinator.kt +26 -13
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/NimoCanvasSession.kt +31 -19
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/SerializedGattCallback.kt +5 -0
- package/android/src/test/java/com/mentra/bluetoothsdk/DeviceManagerSceneHandoffTest.kt +111 -2
- package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/MentraLiveGattCallbackTest.kt +33 -0
- package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/NimoCanvasCoordinatorTest.kt +44 -4
- package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/NimoCanvasSessionTest.kt +82 -13
- package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/NimoMicReconnectTest.kt +93 -0
- package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/NimoProtocolTest.kt +11 -0
- package/android/src/testDebug/java/com/mentra/bluetoothsdk/sgcs/NimoLifecycleTest.kt +94 -0
- package/android/src/testDebug/java/com/mentra/bluetoothsdk/sgcs/NimoReadinessDriverTest.kt +15 -9
- package/build/BluetoothSdk.types.d.ts +2 -0
- package/build/BluetoothSdk.types.d.ts.map +1 -1
- package/build/BluetoothSdk.types.js +1 -0
- package/build/BluetoothSdk.types.js.map +1 -1
- package/build/_private/BluetoothSdkModule.d.ts +2 -0
- package/build/_private/BluetoothSdkModule.d.ts.map +1 -1
- package/build/_private/BluetoothSdkModule.js.map +1 -1
- package/build/generated/releaseMetadata.js +5 -5
- package/build/generated/releaseMetadata.js.map +1 -1
- package/ios/BluetoothSdkModule.swift +4 -0
- package/ios/Source/BluetoothSdkDefaults.swift +1 -1
- package/ios/Source/DeviceManager.swift +66 -50
- package/ios/Source/GeneratedReleaseMetadata.swift +5 -5
- package/ios/Source/audio/GlassesMicWatchdog.swift +33 -0
- package/ios/Source/sgcs/Ar99.swift +1 -2
- package/ios/Source/sgcs/G2.swift +259 -245
- package/ios/Source/sgcs/G2ConnectionSession.swift +101 -0
- package/ios/Source/sgcs/Nimo.swift +104 -43
- package/ios/Source/sgcs/NimoCanvasCoordinator.swift +20 -6
- package/ios/Source/sgcs/NimoCanvasSession.swift +30 -14
- package/ios/Source/sgcs/NimoDiscovery.swift +49 -0
- package/ios/Source/status/DeviceStatus.swift +1 -0
- package/ios/Source/stt/SherpaOnnxTranscriber.swift +49 -189
- package/ios/Source/utils/DemandDrivenAudioWorker.swift +150 -0
- package/ios/Tests/DemandDrivenAudioWorkerTests.swift +157 -0
- package/ios/Tests/DeviceManagerReconnectTests.swift +362 -0
- package/ios/Tests/G2ConnectionSessionTests.swift +172 -0
- package/ios/Tests/NimoCanvasSessionTests.swift +74 -5
- package/ios/Tests/NimoCanvasTransportTests.swift +25 -0
- package/ios/Tests/NimoDeviceCommandsTests.swift +16 -0
- package/ios/Tests/NimoDiscoveryTests.swift +79 -0
- package/package.json +2 -2
- package/src/BluetoothSdk.types.ts +3 -0
- package/src/_private/BluetoothSdkModule.ts +2 -0
- package/src/generated/releaseMetadata.ts +5 -5
|
@@ -550,6 +550,8 @@ class BluetoothSdkModule : Module() {
|
|
|
550
550
|
|
|
551
551
|
SdkAsyncFunction("forget") { -> sdk?.forget() }
|
|
552
552
|
|
|
553
|
+
SdkCoroutineFunction("unpair") { -> deviceManager?.unpair() }
|
|
554
|
+
|
|
553
555
|
AsyncFunction("connectDefaultController") { deviceManager?.connectDefaultController() }
|
|
554
556
|
|
|
555
557
|
AsyncFunction("disconnectController") { deviceManager?.disconnectController() }
|
|
@@ -35,7 +35,10 @@ import com.mentra.bluetoothsdk.utils.MicTypes
|
|
|
35
35
|
import com.mentra.bluetoothsdk.utils.PhoneAudioMonitor
|
|
36
36
|
import com.mentra.lc3Lib.Lc3Cpp
|
|
37
37
|
import com.mentra.bluetoothsdk.stt.SherpaOnnxTranscriber
|
|
38
|
+
import kotlin.coroutines.resume
|
|
39
|
+
import kotlinx.coroutines.suspendCancellableCoroutine
|
|
38
40
|
import kotlinx.coroutines.CoroutineScope
|
|
41
|
+
import kotlinx.coroutines.withContext
|
|
39
42
|
import kotlinx.coroutines.Dispatchers
|
|
40
43
|
import kotlinx.coroutines.launch
|
|
41
44
|
import java.text.SimpleDateFormat
|
|
@@ -1378,7 +1381,8 @@ class DeviceManager internal constructor(initializeHardware: Boolean) {
|
|
|
1378
1381
|
Bridge.log("MAN: Cleaning up previous sgc type: ${sgc?.type}")
|
|
1379
1382
|
sgc?.cleanup()
|
|
1380
1383
|
sgc = null
|
|
1381
|
-
|
|
1384
|
+
DeviceStore.apply("glasses", "micEnabled", false)
|
|
1385
|
+
resetConnectionReadyState()
|
|
1382
1386
|
}
|
|
1383
1387
|
|
|
1384
1388
|
if (sgc != null) {
|
|
@@ -1536,6 +1540,7 @@ class DeviceManager internal constructor(initializeHardware: Boolean) {
|
|
|
1536
1540
|
// re-apply display height/depth after reconnection
|
|
1537
1541
|
mainHandler.postDelayed(
|
|
1538
1542
|
{
|
|
1543
|
+
val device = sgc ?: return@postDelayed
|
|
1539
1544
|
val h =
|
|
1540
1545
|
(DeviceStore.store.get("bluetooth", "dashboard_height") as? Number)
|
|
1541
1546
|
?.toInt()
|
|
@@ -1544,8 +1549,8 @@ class DeviceManager internal constructor(initializeHardware: Boolean) {
|
|
|
1544
1549
|
(DeviceStore.store.get("bluetooth", "dashboard_depth") as? Number)
|
|
1545
1550
|
?.toInt()
|
|
1546
1551
|
?: dashboardDepth // canonical default (2), not 1
|
|
1547
|
-
val d = rawDepth.coerceIn(1, 4)
|
|
1548
|
-
|
|
1552
|
+
val d = if (device.type == DeviceTypes.NIMO) rawDepth.coerceIn(0, 10) else rawDepth.coerceIn(1, 4)
|
|
1553
|
+
device.setDashboardPosition(h, d)
|
|
1549
1554
|
},
|
|
1550
1555
|
2000
|
|
1551
1556
|
)
|
|
@@ -1574,10 +1579,11 @@ class DeviceManager internal constructor(initializeHardware: Boolean) {
|
|
|
1574
1579
|
handleMach1Ready() // Z100 uses same initialization as Mach1
|
|
1575
1580
|
}
|
|
1576
1581
|
|
|
1577
|
-
//
|
|
1578
|
-
//
|
|
1582
|
+
// Disconnect clears micEnabled but preserves the consumers' audio requests.
|
|
1583
|
+
// Recompute that derived flag before selecting a microphone; replaying an
|
|
1584
|
+
// unchanged should_send_* setting is deduplicated by DeviceStore.apply().
|
|
1579
1585
|
Bridge.log("MAN: Re-applying microphone settings after reconnection")
|
|
1580
|
-
|
|
1586
|
+
setMicState()
|
|
1581
1587
|
|
|
1582
1588
|
// send to the server our battery status:
|
|
1583
1589
|
Bridge.sendBatteryStatus(sgc?.batteryLevel ?: -1, false)
|
|
@@ -1617,7 +1623,11 @@ class DeviceManager internal constructor(initializeHardware: Boolean) {
|
|
|
1617
1623
|
mainHandler.postDelayed(sync, 3000)
|
|
1618
1624
|
}
|
|
1619
1625
|
|
|
1620
|
-
private fun
|
|
1626
|
+
private fun resetConnectionReadyState() {
|
|
1627
|
+
// Suppress duplicate readiness only within one connection, never across
|
|
1628
|
+
// a genuine disconnect/reconnect of the same glasses inside two seconds.
|
|
1629
|
+
lastReadyHandledKey = ""
|
|
1630
|
+
lastReadyHandledAtMs = 0L
|
|
1621
1631
|
pendingSystemTimeSync?.let { mainHandler.removeCallbacks(it) }
|
|
1622
1632
|
pendingSystemTimeSync = null
|
|
1623
1633
|
lastSystemTimeSyncConnectionKey = ""
|
|
@@ -1635,7 +1645,7 @@ class DeviceManager internal constructor(initializeHardware: Boolean) {
|
|
|
1635
1645
|
|
|
1636
1646
|
fun handleDeviceDisconnected() {
|
|
1637
1647
|
Bridge.log("MAN: Device disconnected")
|
|
1638
|
-
|
|
1648
|
+
resetConnectionReadyState()
|
|
1639
1649
|
resetMicHealth()
|
|
1640
1650
|
DeviceStore.apply("glasses", "headUp", false)
|
|
1641
1651
|
DeviceStore.apply(
|
|
@@ -2445,7 +2455,10 @@ class DeviceManager internal constructor(initializeHardware: Boolean) {
|
|
|
2445
2455
|
val device = sgc
|
|
2446
2456
|
if (device is Nimo) device.cleanup() else device?.disconnect()
|
|
2447
2457
|
sgc = null // Clear the SGC reference after disconnect
|
|
2448
|
-
|
|
2458
|
+
// This cache belongs to the discarded connection. Keep consumer demand,
|
|
2459
|
+
// but require a new mic-enable command when replacement glasses are ready.
|
|
2460
|
+
DeviceStore.apply("glasses", "micEnabled", false)
|
|
2461
|
+
resetConnectionReadyState()
|
|
2449
2462
|
resetMicHealth()
|
|
2450
2463
|
searching = false
|
|
2451
2464
|
micEnabled = false
|
|
@@ -2492,6 +2505,37 @@ class DeviceManager internal constructor(initializeHardware: Boolean) {
|
|
|
2492
2505
|
controller = null
|
|
2493
2506
|
}
|
|
2494
2507
|
|
|
2508
|
+
private var unpairInProgress = false
|
|
2509
|
+
|
|
2510
|
+
/** Explicit user Unpair, separate from passive pairing cleanup and logout. */
|
|
2511
|
+
suspend fun unpair() = withContext(Dispatchers.Main) {
|
|
2512
|
+
check(!unpairInProgress) { "Unpair already in progress" }
|
|
2513
|
+
unpairInProgress = true
|
|
2514
|
+
try {
|
|
2515
|
+
val target = sgc
|
|
2516
|
+
val nimo = target as? Nimo
|
|
2517
|
+
val savedNimoAddress = when {
|
|
2518
|
+
defaultWearable == DeviceTypes.NIMO -> deviceAddress
|
|
2519
|
+
pendingWearable == DeviceTypes.NIMO -> pendingDeviceAddress
|
|
2520
|
+
else -> null
|
|
2521
|
+
}
|
|
2522
|
+
if (nimo != null) {
|
|
2523
|
+
suspendCancellableCoroutine<Boolean> { continuation ->
|
|
2524
|
+
nimo.resetForUnpair { if (continuation.isActive) continuation.resume(it) }
|
|
2525
|
+
}
|
|
2526
|
+
check(sgc === target) { "Glasses changed during Unpair" }
|
|
2527
|
+
// Drop the encrypted link before removing the phone's bond.
|
|
2528
|
+
nimo.disconnect()
|
|
2529
|
+
nimo.removeBluetoothBond()
|
|
2530
|
+
} else if (target == null && savedNimoAddress != null) {
|
|
2531
|
+
Nimo.removeBluetoothBond(savedNimoAddress)
|
|
2532
|
+
}
|
|
2533
|
+
forget()
|
|
2534
|
+
} finally {
|
|
2535
|
+
unpairInProgress = false
|
|
2536
|
+
}
|
|
2537
|
+
}
|
|
2538
|
+
|
|
2495
2539
|
fun forget() {
|
|
2496
2540
|
synchronized(recoveryLock) {
|
|
2497
2541
|
recoverG2Connection = false
|
|
@@ -2508,7 +2552,8 @@ class DeviceManager internal constructor(initializeHardware: Boolean) {
|
|
|
2508
2552
|
// session state without calling disconnect() again (that would hit a dead instance
|
|
2509
2553
|
// and leave a destroyed MentraLive retained for the next scan).
|
|
2510
2554
|
sgc = null
|
|
2511
|
-
|
|
2555
|
+
DeviceStore.apply("glasses", "micEnabled", false)
|
|
2556
|
+
resetConnectionReadyState()
|
|
2512
2557
|
searching = false
|
|
2513
2558
|
micEnabled = false
|
|
2514
2559
|
updateMicState()
|
|
@@ -3,9 +3,9 @@ package com.mentra.bluetoothsdk
|
|
|
3
3
|
/** Generated by release CI. Do not edit in a release checkout. */
|
|
4
4
|
internal object GeneratedReleaseMetadata {
|
|
5
5
|
const val FAMILY_BASE_VERSION: String = "3.3.0"
|
|
6
|
-
const val RELEASE_IDENTITY: String = "3.3.0-dev.
|
|
7
|
-
const val RELEASE_SET_ID: String = "mentra-3.3.0-dev.
|
|
8
|
-
const val SOURCE_COMMIT: String = "
|
|
9
|
-
const val OTA_MANIFEST_URL: String = "https://artifactscdn.mentraglass.com/Mentra-Community/MentraOS/releases/mentra-builds-v3.3.0/mentra-live-ota-3.3.0-dev.
|
|
10
|
-
const val OTA_MANIFEST_SHA256: String = "
|
|
6
|
+
const val RELEASE_IDENTITY: String = "3.3.0-dev.523"
|
|
7
|
+
const val RELEASE_SET_ID: String = "mentra-3.3.0-dev.523"
|
|
8
|
+
const val SOURCE_COMMIT: String = "0dd41dbb1e055eb1d80fb39d26a7ba20448de657"
|
|
9
|
+
const val OTA_MANIFEST_URL: String = "https://artifactscdn.mentraglass.com/Mentra-Community/MentraOS/releases/mentra-builds-v3.3.0/mentra-live-ota-3.3.0-dev.523.json"
|
|
10
|
+
const val OTA_MANIFEST_SHA256: String = "ad3129a46a5ff8f018c80defcecc6acf88e7ab2d248d0914ed4c2eb097c3f3ec"
|
|
11
11
|
}
|
|
@@ -69,6 +69,7 @@ internal object NimoProtocol {
|
|
|
69
69
|
const val CMD_INSTRUCTION_REPORT = 0x06
|
|
70
70
|
const val CMD_CONTROL_INSTRUCTION = 0x07
|
|
71
71
|
const val CMD_CONTROL_FACTORY = 0x08
|
|
72
|
+
const val FACTORY_RECOVER = 0x03
|
|
72
73
|
const val CMD_CONTROL_NOTIFICATION = 0x09
|
|
73
74
|
|
|
74
75
|
// get parameter keys
|
|
@@ -89,6 +90,9 @@ internal object NimoProtocol {
|
|
|
89
90
|
const val SET_DISPLAY_OFF = 0x0F
|
|
90
91
|
const val SET_PHONE_TYPE = 0x14
|
|
91
92
|
const val SET_HEIGHT_LEVEL = 0x17
|
|
93
|
+
// Dynamic-v1 firmware: 1-based UI locale; 1 selects English.
|
|
94
|
+
const val SET_SYSTEM_LANGUAGE = 0x24
|
|
95
|
+
const val LANGUAGE_ENGLISH = 0x01
|
|
92
96
|
|
|
93
97
|
// control instruction keys
|
|
94
98
|
const val CTRL_ENTER_APP = 0x01
|
|
@@ -889,6 +893,17 @@ class Nimo : SGCManager() {
|
|
|
889
893
|
private const val KEY_LAST_ADDRESS = "nimo_lastDeviceAddress"
|
|
890
894
|
private const val KEY_LAST_NAME = "nimo_lastDeviceName"
|
|
891
895
|
|
|
896
|
+
internal fun removeBluetoothBond(address: String?) {
|
|
897
|
+
if (address.isNullOrBlank()) return
|
|
898
|
+
try {
|
|
899
|
+
val device = BluetoothAdapter.getDefaultAdapter()?.getRemoteDevice(address) ?: return
|
|
900
|
+
val removed = device.javaClass.getMethod("removeBond").invoke(device) as? Boolean
|
|
901
|
+
Bridge.log("NIMO: Bluetooth unbond requested=$removed")
|
|
902
|
+
} catch (error: Exception) {
|
|
903
|
+
Bridge.log("NIMO: Bluetooth unbond failed: ${error.message}")
|
|
904
|
+
}
|
|
905
|
+
}
|
|
906
|
+
|
|
892
907
|
private const val TWS_TIMEOUT_MS = 10_000L
|
|
893
908
|
private const val ACK_TIMEOUT_MS = 5_000L
|
|
894
909
|
// Generous: the glasses' classic bond can take 30-45s PER attempt and may need
|
|
@@ -1097,6 +1112,31 @@ class Nimo : SGCManager() {
|
|
|
1097
1112
|
DeviceStore.apply("glasses", "connectionState", ConnTypes.DISCONNECTED)
|
|
1098
1113
|
}
|
|
1099
1114
|
|
|
1115
|
+
/** Explicit Unpair only. Ordinary forget/cleanup must never reset the glasses. */
|
|
1116
|
+
internal fun resetForUnpair(onResult: (Boolean) -> Unit) {
|
|
1117
|
+
check(Looper.myLooper() == mainHandler.looper)
|
|
1118
|
+
if (gatt == null || txChar == null || handshakeState != HandshakeState.READY) {
|
|
1119
|
+
Bridge.log("NIMO: unpair while offline; remote reset unavailable")
|
|
1120
|
+
onResult(false)
|
|
1121
|
+
return
|
|
1122
|
+
}
|
|
1123
|
+
isDisconnecting = true
|
|
1124
|
+
stopTimers()
|
|
1125
|
+
reconnectionManager.stop()
|
|
1126
|
+
canvasEncoder.invalidate()
|
|
1127
|
+
canvas.disconnected()
|
|
1128
|
+
Bridge.log("NIMO: sending factory reset for explicit Unpair")
|
|
1129
|
+
sendAwaitingAck(NimoProtocol.CMD_CONTROL_FACTORY, NimoProtocol.FACTORY_RECOVER, byteArrayOf()) {
|
|
1130
|
+
Bridge.log("NIMO: factory reset acknowledged=$it")
|
|
1131
|
+
onResult(it)
|
|
1132
|
+
}
|
|
1133
|
+
}
|
|
1134
|
+
|
|
1135
|
+
internal fun removeBluetoothBond() {
|
|
1136
|
+
val address = gatt?.device?.address ?: lastDeviceAddress ?: return
|
|
1137
|
+
removeBluetoothBond(address)
|
|
1138
|
+
}
|
|
1139
|
+
|
|
1100
1140
|
override fun forget() {
|
|
1101
1141
|
if (Looper.myLooper() != mainHandler.looper) {
|
|
1102
1142
|
runBlocking { withContext(Dispatchers.Main) { forget() } }
|
|
@@ -1980,7 +2020,10 @@ class Nimo : SGCManager() {
|
|
|
1980
2020
|
}
|
|
1981
2021
|
val timeout = Runnable {
|
|
1982
2022
|
// Do not retry a timed-out key on the same transport: a late response is uncorrelated.
|
|
1983
|
-
|
|
2023
|
+
pendingAcks.remove(ackKey)?.let { expired ->
|
|
2024
|
+
abortTransport("ACK timeout cmd=$cmd key=$key")
|
|
2025
|
+
expired.onResult(false)
|
|
2026
|
+
}
|
|
1984
2027
|
}
|
|
1985
2028
|
pendingAcks[ackKey] = PendingAck(onResult, timeout)
|
|
1986
2029
|
mainHandler.postDelayed(timeout, timeoutMs)
|
|
@@ -2080,6 +2123,9 @@ class Nimo : SGCManager() {
|
|
|
2080
2123
|
needsAck = false
|
|
2081
2124
|
)
|
|
2082
2125
|
)
|
|
2126
|
+
// Optional setting: older firmware may reject it without preventing connection.
|
|
2127
|
+
sendFrame(NimoFrameCodec.encodeFrame(NimoProtocol.CMD_SET_PARAMETER,
|
|
2128
|
+
NimoProtocol.SET_SYSTEM_LANGUAGE, byteArrayOf(NimoProtocol.LANGUAGE_ENGLISH.toByte())))
|
|
2083
2129
|
getBatteryStatus()
|
|
2084
2130
|
requestVersionInfo()
|
|
2085
2131
|
|
|
@@ -2099,7 +2145,10 @@ class Nimo : SGCManager() {
|
|
|
2099
2145
|
)
|
|
2100
2146
|
}
|
|
2101
2147
|
diagnostics?.connected((negotiatedMtu - 3).coerceIn(20, 512))
|
|
2102
|
-
|
|
2148
|
+
// Launch checks peer readiness in firmware; do not wait up to a minute for
|
|
2149
|
+
// the first unsolicited heartbeat just because peer status is still unknown.
|
|
2150
|
+
canvas.activate()
|
|
2151
|
+
canvas.readiness(twsConnected && peerCompanionReady != false)
|
|
2103
2152
|
}
|
|
2104
2153
|
|
|
2105
2154
|
private fun handshakeFailed() {
|
|
@@ -2117,6 +2166,10 @@ class Nimo : SGCManager() {
|
|
|
2117
2166
|
canvasEncoder.invalidate()
|
|
2118
2167
|
canvas.disconnected()
|
|
2119
2168
|
audioClient.stop()
|
|
2169
|
+
// Nimo's decoder/stream belongs to this GATT session. Consumer demand
|
|
2170
|
+
// remains in bluetooth.should_send_*; readiness must start a fresh stream
|
|
2171
|
+
// even when reconnecting with this same adapter instance.
|
|
2172
|
+
DeviceStore.apply("glasses", "micEnabled", false)
|
|
2120
2173
|
receiveAssembler.reset()
|
|
2121
2174
|
writes.reset()
|
|
2122
2175
|
negotiatedMtu = 23
|
|
@@ -2171,6 +2224,7 @@ class Nimo : SGCManager() {
|
|
|
2171
2224
|
if (diagnostics?.onPacket(packet) == true) return
|
|
2172
2225
|
if (packet.size >= 10 && packet[8].toInt() == 7 && (packet[9].toInt() and 255) in listOf(1, 3, 4)) {
|
|
2173
2226
|
val response = NimoCanvasCodec.response(packet) ?: return
|
|
2227
|
+
if (response.first == 1) Bridge.log("NIMO: canvas Launch response status=${response.second.firstOrNull()?.toInt()}")
|
|
2174
2228
|
canvas.response(response.first, response.second)
|
|
2175
2229
|
return
|
|
2176
2230
|
}
|
|
@@ -2202,13 +2256,13 @@ class Nimo : SGCManager() {
|
|
|
2202
2256
|
if (appId != NimoCanvasCodec.APP_ID) {
|
|
2203
2257
|
diagnostics?.cancelHeldCapture("Native app takeover preempted held capture", resumeCanvas = false)
|
|
2204
2258
|
}
|
|
2205
|
-
|
|
2259
|
+
canvas.nativeApp(appId, true)
|
|
2206
2260
|
}
|
|
2207
2261
|
NimoProtocol.STATE_EXIT -> {
|
|
2208
2262
|
if (appId == NimoCanvasCodec.APP_ID) {
|
|
2209
2263
|
diagnostics?.cancelHeldCapture("Canvas exit preempted held capture", resumeCanvas = false)
|
|
2210
2264
|
}
|
|
2211
|
-
|
|
2265
|
+
canvas.nativeApp(appId, false)
|
|
2212
2266
|
}
|
|
2213
2267
|
}
|
|
2214
2268
|
}
|
|
@@ -2240,7 +2294,7 @@ class Nimo : SGCManager() {
|
|
|
2240
2294
|
// Both predicates were sampled by this device report. TWS-only
|
|
2241
2295
|
// reports and cached state cannot release a status-7 wait.
|
|
2242
2296
|
if (handshakeState == HandshakeState.READY) {
|
|
2243
|
-
canvas.confirmedReadiness(twsConnected && peerCompanionReady
|
|
2297
|
+
canvas.confirmedReadiness(twsConnected && peerCompanionReady != false)
|
|
2244
2298
|
}
|
|
2245
2299
|
}
|
|
2246
2300
|
}
|
|
@@ -2266,7 +2320,7 @@ class Nimo : SGCManager() {
|
|
|
2266
2320
|
if (!connected && handshakeState == HandshakeState.READY) {
|
|
2267
2321
|
Bridge.log("NIMO: TWS service dropped mid-session (arm removed/off?)")
|
|
2268
2322
|
}
|
|
2269
|
-
if (handshakeState == HandshakeState.READY) canvas.readiness(connected && peerCompanionReady
|
|
2323
|
+
if (handshakeState == HandshakeState.READY) canvas.readiness(connected && peerCompanionReady != false)
|
|
2270
2324
|
}
|
|
2271
2325
|
|
|
2272
2326
|
private fun handleInputEvent(code: Int) {
|
|
@@ -2302,6 +2356,9 @@ class Nimo : SGCManager() {
|
|
|
2302
2356
|
// 5 not supported, 6 bad parameter, 7 device busy.
|
|
2303
2357
|
Bridge.log("NIMO: response cmd=$cmd key=$key error status=$statusCode")
|
|
2304
2358
|
}
|
|
2359
|
+
if (cmd == NimoProtocol.CMD_SET_PARAMETER && key == NimoProtocol.SET_SYSTEM_LANGUAGE) {
|
|
2360
|
+
Bridge.log("NIMO: English menu setting acknowledged=${statusCode == 0} status=$statusCode")
|
|
2361
|
+
}
|
|
2305
2362
|
resolvePendingAck(cmd, key, statusCode == 0)
|
|
2306
2363
|
|
|
2307
2364
|
if (cmd != NimoProtocol.CMD_GET_PARAMETER || statusCode != 0) return
|
|
@@ -132,6 +132,7 @@ internal class NimoCanvasCoordinator(
|
|
|
132
132
|
private var holding = false
|
|
133
133
|
private var heldScope: String? = null
|
|
134
134
|
private var cancelDeadline: (() -> Unit)? = null
|
|
135
|
+
private var cancelRetry: (() -> Unit)? = null
|
|
135
136
|
|
|
136
137
|
fun offer(bytes: ByteArray, scope: String, force: Boolean = false) {
|
|
137
138
|
if (holding && scope != heldScope) {
|
|
@@ -140,8 +141,15 @@ internal class NimoCanvasCoordinator(
|
|
|
140
141
|
}
|
|
141
142
|
run(session.offer(bytes, scope, force))
|
|
142
143
|
}
|
|
143
|
-
fun
|
|
144
|
-
fun
|
|
144
|
+
fun activate() = run(session.activate())
|
|
145
|
+
fun readiness(ready: Boolean) {
|
|
146
|
+
if (!ready) { cancelRetry?.invoke(); cancelRetry = null }
|
|
147
|
+
run(session.readiness(ready))
|
|
148
|
+
}
|
|
149
|
+
fun confirmedReadiness(ready: Boolean) {
|
|
150
|
+
if (!ready) { cancelRetry?.invoke(); cancelRetry = null }
|
|
151
|
+
run(session.confirmedReadiness(ready))
|
|
152
|
+
}
|
|
145
153
|
fun hold(onReady: () -> Unit) {
|
|
146
154
|
session.hold(true)
|
|
147
155
|
holding = true
|
|
@@ -155,19 +163,14 @@ internal class NimoCanvasCoordinator(
|
|
|
155
163
|
heldScope = null
|
|
156
164
|
run(session.hold(false, resume))
|
|
157
165
|
}
|
|
158
|
-
fun exit()
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
fun nativeApp(appId: Int, entered: Boolean): Boolean {
|
|
162
|
-
val takeover = (appId == NimoCanvasCodec.APP_ID && !entered) ||
|
|
163
|
-
(appId != NimoCanvasCodec.APP_ID && entered)
|
|
164
|
-
val expectedExitReport = appId == NimoCanvasCodec.APP_ID && !entered && flight?.action?.key == 3
|
|
165
|
-
val invalidateEncoding = takeover && !expectedExitReport
|
|
166
|
-
run(session.nativeApp(appId, entered))
|
|
167
|
-
return invalidateEncoding
|
|
166
|
+
fun exit() {
|
|
167
|
+
cancelRetry?.invoke(); cancelRetry = null
|
|
168
|
+
run(session.exit())
|
|
168
169
|
}
|
|
170
|
+
fun nativeApp(appId: Int, entered: Boolean) = run(session.nativeApp(appId, entered))
|
|
169
171
|
|
|
170
172
|
fun disconnected() {
|
|
173
|
+
cancelRetry?.invoke(); cancelRetry = null
|
|
171
174
|
cancelDeadline?.invoke(); cancelDeadline = null
|
|
172
175
|
flight = null
|
|
173
176
|
holdReady = null
|
|
@@ -199,6 +202,7 @@ internal class NimoCanvasCoordinator(
|
|
|
199
202
|
private fun run(actions: List<NimoCanvasSession.Action>) {
|
|
200
203
|
for (action in actions) when (action) {
|
|
201
204
|
is NimoCanvasSession.Action.Send -> {
|
|
205
|
+
cancelRetry?.invoke(); cancelRetry = null
|
|
202
206
|
cancelDeadline?.invoke()
|
|
203
207
|
val current = Flight(action)
|
|
204
208
|
flight = current
|
|
@@ -219,7 +223,16 @@ internal class NimoCanvasCoordinator(
|
|
|
219
223
|
}
|
|
220
224
|
}
|
|
221
225
|
is NimoCanvasSession.Action.Reconnect -> { disconnected(); reconnect(action.reason) }
|
|
222
|
-
is NimoCanvasSession.Action.Rejected ->
|
|
226
|
+
is NimoCanvasSession.Action.Rejected -> {
|
|
227
|
+
rejected(action.status)
|
|
228
|
+
if (action.status == 7) {
|
|
229
|
+
cancelRetry?.invoke()
|
|
230
|
+
cancelRetry = scheduler.post(1_000) {
|
|
231
|
+
cancelRetry = null
|
|
232
|
+
run(session.retryNotReady())
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
}
|
|
223
236
|
}
|
|
224
237
|
}
|
|
225
238
|
}
|
|
@@ -9,6 +9,7 @@ internal class NimoCanvasSession {
|
|
|
9
9
|
}
|
|
10
10
|
private data class Flight(val key: Int, val frame: ByteArray, val ticket: Long)
|
|
11
11
|
private var ready = false
|
|
12
|
+
private var transportReady = false
|
|
12
13
|
private var active = false
|
|
13
14
|
private var held = false
|
|
14
15
|
private var locked = false
|
|
@@ -16,6 +17,7 @@ internal class NimoCanvasSession {
|
|
|
16
17
|
private var waitingReadiness = false
|
|
17
18
|
private var observedNotReady = false
|
|
18
19
|
private var readinessRetries = 0
|
|
20
|
+
private var probeRetries = 0
|
|
19
21
|
private var exitRequested = false
|
|
20
22
|
private var desired: ByteArray? = null
|
|
21
23
|
private var accepted: ByteArray? = null
|
|
@@ -35,6 +37,23 @@ internal class NimoCanvasSession {
|
|
|
35
37
|
return pump()
|
|
36
38
|
}
|
|
37
39
|
|
|
40
|
+
/** Enter a blank canvas on a fresh connection unless a newer host scene is retained. */
|
|
41
|
+
fun activate(): List<Action> {
|
|
42
|
+
if (desired == null) desired = byteArrayOf(0, 0, 1)
|
|
43
|
+
exitRequested = false
|
|
44
|
+
return pump()
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Launch itself checks peer readiness in firmware. Retry only explicit NOT_READY,
|
|
48
|
+
* with a small budget; never retry an ambiguous/missing business ACK on this link. */
|
|
49
|
+
fun retryNotReady(): List<Action> {
|
|
50
|
+
if (!transportReady || !waitingReadiness || desired == null || probeRetries >= 3) return emptyList()
|
|
51
|
+
probeRetries++
|
|
52
|
+
waitingReadiness = false
|
|
53
|
+
ready = true
|
|
54
|
+
return pump()
|
|
55
|
+
}
|
|
56
|
+
|
|
38
57
|
/** Debug capture seam; production behavior is unchanged unless explicitly held. */
|
|
39
58
|
fun hold(value: Boolean, resume: Boolean = true): List<Action> {
|
|
40
59
|
held = value
|
|
@@ -50,7 +69,8 @@ internal class NimoCanvasSession {
|
|
|
50
69
|
fun confirmedReadiness(value: Boolean): List<Action> = updateReadiness(value, confirmed = true)
|
|
51
70
|
|
|
52
71
|
private fun updateReadiness(value: Boolean, confirmed: Boolean): List<Action> {
|
|
53
|
-
|
|
72
|
+
transportReady = value
|
|
73
|
+
if (!value) { readinessRetries = 0; probeRetries = 0 }
|
|
54
74
|
if (!value && flight != null) return resetLink("Readiness lost during canvas command")
|
|
55
75
|
if (waitingReadiness) {
|
|
56
76
|
if (!value) observedNotReady = true
|
|
@@ -67,9 +87,9 @@ internal class NimoCanvasSession {
|
|
|
67
87
|
|
|
68
88
|
/** A new transport generation has no accepted frame. Retain only the latest desired scene. */
|
|
69
89
|
fun disconnected() {
|
|
70
|
-
ready = false; active = false; held = false; locked = false; launchBlocked = false
|
|
90
|
+
ready = false; transportReady = false; active = false; held = false; locked = false; launchBlocked = false
|
|
71
91
|
waitingReadiness = false; observedNotReady = false
|
|
72
|
-
readinessRetries = 0
|
|
92
|
+
readinessRetries = 0; probeRetries = 0
|
|
73
93
|
accepted = null; rejected = null; flight = null; ticket++; forceRevision++
|
|
74
94
|
}
|
|
75
95
|
|
|
@@ -78,21 +98,13 @@ internal class NimoCanvasSession {
|
|
|
78
98
|
return pump()
|
|
79
99
|
}
|
|
80
100
|
|
|
81
|
-
/**
|
|
101
|
+
/** Firmware can restore its stock UI after reconnect. Keep host ownership and
|
|
102
|
+
* replay the newest scene, serializing behind any outstanding command ACK. */
|
|
82
103
|
fun nativeApp(appId: Int, entered: Boolean): List<Action> {
|
|
83
104
|
if ((appId == NimoCanvasCodec.APP_ID && !entered) || (appId != NimoCanvasCodec.APP_ID && entered)) {
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
active = false; accepted = null
|
|
88
|
-
if (appId != NimoCanvasCodec.APP_ID && entered) {
|
|
89
|
-
desired = null; rejected = null; scope = null
|
|
90
|
-
}
|
|
91
|
-
return emptyList()
|
|
92
|
-
}
|
|
93
|
-
val ambiguous = flight != null
|
|
94
|
-
desired = null; accepted = null; rejected = null; active = false; exitRequested = false; scope = null
|
|
95
|
-
if (ambiguous) return resetLink("Native app transition interrupted canvas command")
|
|
105
|
+
active = false
|
|
106
|
+
accepted = null
|
|
107
|
+
return pump()
|
|
96
108
|
}
|
|
97
109
|
return emptyList()
|
|
98
110
|
}
|
|
@@ -130,8 +142,8 @@ internal class NimoCanvasSession {
|
|
|
130
142
|
return listOf(Action.Rejected(status)) + pump()
|
|
131
143
|
}
|
|
132
144
|
when (key) {
|
|
133
|
-
1 -> active = true
|
|
134
|
-
4 -> { accepted = current.frame; rejected = null; readinessRetries = 0 }
|
|
145
|
+
1 -> { active = true; accepted = null }
|
|
146
|
+
4 -> { accepted = current.frame; rejected = null; readinessRetries = 0; probeRetries = 0 }
|
|
135
147
|
3 -> { active = false; accepted = null; exitRequested = false }
|
|
136
148
|
}
|
|
137
149
|
return pump()
|
|
@@ -143,7 +155,7 @@ internal class NimoCanvasSession {
|
|
|
143
155
|
}
|
|
144
156
|
|
|
145
157
|
private fun resetLink(reason: String): List<Action> {
|
|
146
|
-
ready = false; active = false; accepted = null; flight = null; ticket++; forceRevision++
|
|
158
|
+
ready = false; transportReady = false; active = false; accepted = null; flight = null; ticket++; forceRevision++
|
|
147
159
|
return listOf(Action.Reconnect(reason))
|
|
148
160
|
}
|
|
149
161
|
|
|
@@ -4,6 +4,7 @@ import android.bluetooth.BluetoothGatt
|
|
|
4
4
|
import android.bluetooth.BluetoothGattCallback
|
|
5
5
|
import android.bluetooth.BluetoothGattCharacteristic
|
|
6
6
|
import android.bluetooth.BluetoothGattDescriptor
|
|
7
|
+
import android.os.Build
|
|
7
8
|
|
|
8
9
|
/**
|
|
9
10
|
* Binder callbacks only capture their arguments. Session validation and all callback effects run
|
|
@@ -45,6 +46,10 @@ internal abstract class SerializedGattCallback(
|
|
|
45
46
|
final override fun onCharacteristicChanged(
|
|
46
47
|
gatt: BluetoothGatt, characteristic: BluetoothGattCharacteristic,
|
|
47
48
|
) {
|
|
49
|
+
// Android 13 introduced the value callback; some framework versions also call
|
|
50
|
+
// this deprecated overload for the same notification. Decoding both replays
|
|
51
|
+
// each LC3 packet and doubles recorded audio. Older Android uses this path.
|
|
52
|
+
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) return
|
|
48
53
|
val value = characteristic.value?.copyOf() ?: return
|
|
49
54
|
dispatch(gatt) { handleCharacteristicChanged(gatt, characteristic, value) }
|
|
50
55
|
}
|
|
@@ -24,6 +24,110 @@ import java.util.concurrent.TimeUnit
|
|
|
24
24
|
class DeviceManagerSceneHandoffTest {
|
|
25
25
|
@Before fun setup() { Bridge.initialize(ApplicationProvider.getApplicationContext()) }
|
|
26
26
|
|
|
27
|
+
@Test @LooperMode(LooperMode.Mode.PAUSED)
|
|
28
|
+
fun delayedReconnectReplayPreservesNimoDepthEndpoints() {
|
|
29
|
+
for (depth in listOf(0, 10)) {
|
|
30
|
+
withReadyRecordingDevice { manager, device, _ ->
|
|
31
|
+
DeviceStore.set("bluetooth", "dashboard_height", 7)
|
|
32
|
+
DeviceStore.set("bluetooth", "dashboard_depth", depth)
|
|
33
|
+
manager.handleDeviceReady()
|
|
34
|
+
assertTrue(device.positions.isEmpty())
|
|
35
|
+
Shadows.shadowOf(Looper.getMainLooper()).idleFor(2, TimeUnit.SECONDS)
|
|
36
|
+
assertEquals(listOf(7 to depth), device.positions)
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
@Test @LooperMode(LooperMode.Mode.PAUSED)
|
|
42
|
+
fun reconnectRestoresUnchangedAudioRequests() {
|
|
43
|
+
for (request in listOf("should_send_lc3", "should_send_pcm", "should_send_transcript", "local_stt_fallback_active")) {
|
|
44
|
+
withReadyRecordingDevice { manager, original, _ ->
|
|
45
|
+
for (key in listOf("should_send_lc3", "should_send_pcm", "should_send_transcript", "local_stt_fallback_active")) {
|
|
46
|
+
DeviceStore.set("bluetooth", key, key == request)
|
|
47
|
+
}
|
|
48
|
+
DeviceStore.set("bluetooth", "micRanking", listOf("glasses"))
|
|
49
|
+
DeviceStore.set("glasses", "micEnabled", false)
|
|
50
|
+
original.hasMic = true
|
|
51
|
+
manager.setMicState()
|
|
52
|
+
assertEquals(true, DeviceStore.get("bluetooth", "micEnabled"))
|
|
53
|
+
assertEquals(true, DeviceStore.get("glasses", "micEnabled"))
|
|
54
|
+
|
|
55
|
+
repeat(3) {
|
|
56
|
+
manager.disconnect()
|
|
57
|
+
assertEquals(false, DeviceStore.get("bluetooth", "micEnabled"))
|
|
58
|
+
assertEquals(false, DeviceStore.get("glasses", "micEnabled"))
|
|
59
|
+
assertEquals(true, DeviceStore.get("bluetooth", request))
|
|
60
|
+
val replacement = NimoRecordingSGC().apply { hasMic = true }
|
|
61
|
+
manager.sgc = replacement
|
|
62
|
+
|
|
63
|
+
// No test reset of glasses.micEnabled: production teardown must
|
|
64
|
+
// clear it. Replaying the same consumer request is deduplicated.
|
|
65
|
+
DeviceStore.apply("bluetooth", request, true)
|
|
66
|
+
assertEquals(false, DeviceStore.get("bluetooth", "micEnabled"))
|
|
67
|
+
DeviceStore.apply("glasses", "fullyBooted", true)
|
|
68
|
+
|
|
69
|
+
assertEquals("Reconnect must restore $request", true, DeviceStore.get("bluetooth", "micEnabled"))
|
|
70
|
+
assertEquals(listOf(true), replacement.micChanges)
|
|
71
|
+
assertEquals("glasses", manager.activeMicSource())
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
@Test @LooperMode(LooperMode.Mode.PAUSED)
|
|
78
|
+
fun linkDisconnectRetainsIntentForTheSameCommunicator() {
|
|
79
|
+
withReadyRecordingDevice { manager, original, _ ->
|
|
80
|
+
original.hasMic = true
|
|
81
|
+
DeviceStore.set("bluetooth", "micRanking", listOf("glasses"))
|
|
82
|
+
DeviceStore.set("glasses", "micEnabled", false)
|
|
83
|
+
DeviceStore.set("bluetooth", "should_send_lc3", true)
|
|
84
|
+
manager.setMicState()
|
|
85
|
+
assertEquals(listOf(true), original.micChanges)
|
|
86
|
+
DeviceStore.set("glasses", "fullyBooted", true)
|
|
87
|
+
DeviceStore.apply("glasses", "fullyBooted", false)
|
|
88
|
+
assertSame(original, manager.sgc)
|
|
89
|
+
assertEquals(true, DeviceStore.get("bluetooth", "should_send_lc3"))
|
|
90
|
+
assertEquals(true, DeviceStore.get("glasses", "micEnabled"))
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
@Test @LooperMode(LooperMode.Mode.PAUSED)
|
|
95
|
+
fun discardingCommunicatorInvalidatesMicCache() {
|
|
96
|
+
withReadyRecordingDevice { manager, original, _ ->
|
|
97
|
+
original.hasMic = true
|
|
98
|
+
DeviceStore.set("bluetooth", "micRanking", listOf("glasses"))
|
|
99
|
+
DeviceStore.set("glasses", "micEnabled", false)
|
|
100
|
+
DeviceStore.set("bluetooth", "should_send_lc3", true)
|
|
101
|
+
manager.setMicState()
|
|
102
|
+
manager.initSGC(original.type)
|
|
103
|
+
assertEquals(true, DeviceStore.get("glasses", "micEnabled"))
|
|
104
|
+
|
|
105
|
+
// An unsupported model exercises disposal without starting real BLE.
|
|
106
|
+
manager.initSGC("Unavailable test glasses")
|
|
107
|
+
assertNull(manager.sgc)
|
|
108
|
+
assertEquals(false, DeviceStore.get("glasses", "micEnabled"))
|
|
109
|
+
assertEquals(true, DeviceStore.get("bluetooth", "should_send_lc3"))
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
@Test @LooperMode(LooperMode.Mode.PAUSED)
|
|
114
|
+
fun reconnectWithoutAudioRequestsLeavesMicrophoneOff() {
|
|
115
|
+
withReadyRecordingDevice { manager, _, _ ->
|
|
116
|
+
for (key in listOf("should_send_lc3", "should_send_pcm", "should_send_transcript", "local_stt_fallback_active")) {
|
|
117
|
+
DeviceStore.set("bluetooth", key, false)
|
|
118
|
+
}
|
|
119
|
+
DeviceStore.set("glasses", "micEnabled", true)
|
|
120
|
+
manager.disconnect()
|
|
121
|
+
assertEquals(false, DeviceStore.get("glasses", "micEnabled"))
|
|
122
|
+
val replacement = NimoRecordingSGC().apply { hasMic = true }
|
|
123
|
+
manager.sgc = replacement
|
|
124
|
+
DeviceStore.apply("glasses", "fullyBooted", true)
|
|
125
|
+
assertEquals(false, DeviceStore.get("bluetooth", "micEnabled"))
|
|
126
|
+
assertEquals("", manager.activeMicSource())
|
|
127
|
+
assertTrue(replacement.micChanges.isEmpty())
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
27
131
|
@Test @LooperMode(LooperMode.Mode.PAUSED)
|
|
28
132
|
fun nimoBrightnessChangesDoNotReplaceTheSelectedScene() {
|
|
29
133
|
for (headUp in listOf(false, true)) for ((key, value) in brightnessChanges()) {
|
|
@@ -499,8 +603,10 @@ class DeviceManagerSceneHandoffTest {
|
|
|
499
603
|
}
|
|
500
604
|
|
|
501
605
|
private open class RecordingSGC(override val sceneHandoffRequiresClear: Boolean) : SGCManager() {
|
|
606
|
+
val micChanges = mutableListOf<Boolean>()
|
|
502
607
|
val calls = CopyOnWriteArrayList<String>()
|
|
503
608
|
val brightnessCalls = mutableListOf<String>()
|
|
609
|
+
val positions = mutableListOf<Pair<Int, Int>>()
|
|
504
610
|
val textSent = CountDownLatch(1)
|
|
505
611
|
var lastTextNanos = 0L
|
|
506
612
|
var lastClearNanos = 0L
|
|
@@ -511,7 +617,10 @@ class DeviceManagerSceneHandoffTest {
|
|
|
511
617
|
override fun sendTextWall(text: String) { lastTextNanos = System.nanoTime(); calls += "text:$text"; textSent.countDown() }
|
|
512
618
|
override fun disconnect() { calls += "disconnect" }
|
|
513
619
|
override fun cleanup() { calls += "cleanup" }
|
|
514
|
-
override fun setMicEnabled(enabled: Boolean) {
|
|
620
|
+
override fun setMicEnabled(enabled: Boolean) {
|
|
621
|
+
micChanges += enabled
|
|
622
|
+
DeviceStore.set("glasses", "micEnabled", enabled)
|
|
623
|
+
}
|
|
515
624
|
override fun sortMicRanking(list: MutableList<String>) = list
|
|
516
625
|
override fun requestPhoto(request: PhotoRequest) {}
|
|
517
626
|
override fun startStream(message: MutableMap<String, Any>) {}
|
|
@@ -528,7 +637,7 @@ class DeviceManagerSceneHandoffTest {
|
|
|
528
637
|
override fun sendDoubleTextWall(top: String, bottom: String) {}
|
|
529
638
|
override fun displayBitmap(base64ImageData: String, x: Int?, y: Int?, width: Int?, height: Int?) = true
|
|
530
639
|
override fun showDashboard() {}
|
|
531
|
-
override fun setDashboardPosition(height: Int, depth: Int) {}
|
|
640
|
+
override fun setDashboardPosition(height: Int, depth: Int) { positions += height to depth }
|
|
532
641
|
override fun setHeadUpAngle(angle: Int) {}
|
|
533
642
|
override fun getBatteryStatus() {}
|
|
534
643
|
override fun setSilentMode(enabled: Boolean) {}
|