From 79a500150d2ebe2c3fa278f3b2fffd37137042d0 Mon Sep 17 00:00:00 2001 From: subochev Date: Fri, 4 Sep 2026 03:08:38 +0300 Subject: [PATCH] =?UTF-8?q?=D0=9F=D0=B5=D0=B9=D1=80=D0=B8=D0=BD=D0=B3=20Ra?= =?UTF-8?q?yNeo:=20=D0=BA=D0=BD=D0=BE=D0=BF=D0=BA=D0=B0=20=D1=80=D1=83?= =?UTF-8?q?=D1=87=D0=BD=D0=BE=D0=B3=D0=BE=20(=D0=B1=D0=B5=D0=B7=20QR)=20?= =?UTF-8?q?=D1=80=D0=B5-=D0=BF=D0=B0=D1=80=D0=B8=D0=BD=D0=B3=D0=B0=20+=20d?= =?UTF-8?q?ebug-=D1=80=D0=BE=D1=83=D1=82=D1=8B=20+=20keep-screen-on?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - MercuryBridge.pairClassicAuto: классический discovery → найти RayNeo по имени → createBond (без QR и без камеры очков) для случая «бонд слетел, очки в first-pairing». discoverGlasses — список найденных. - MercuryPanel: кнопка «🔗 паринг (вручную)» рядом с QR-парингом - DebugHttpServer: /mercury/pair, /mercury/pair/stop, /mercury/pair/classic, /mercury/unpair, /mercury/bonded, /mercury/discover, поля pairing в /status - keep-screen-on, чтобы QR/состояние реально были на экране телефона --- .../binom/viewmate/phone/DebugHttpServer.kt | 67 +++++++++- .../pw/binom/viewmate/phone/MainActivity.kt | 2 + .../pw/binom/viewmate/phone/MercuryBridge.kt | 118 +++++++++++++++++- .../binom/viewmate/phone/ui/MercuryPanel.kt | 61 +++++++++ .../pw/binom/viewmate/phone/ui/QrCode.kt | 58 +++++++++ 5 files changed, 304 insertions(+), 2 deletions(-) create mode 100644 app-phone/src/main/kotlin/pw/binom/viewmate/phone/ui/QrCode.kt diff --git a/app-phone/src/main/kotlin/pw/binom/viewmate/phone/DebugHttpServer.kt b/app-phone/src/main/kotlin/pw/binom/viewmate/phone/DebugHttpServer.kt index 8c5c9f9..9d47ccc 100644 --- a/app-phone/src/main/kotlin/pw/binom/viewmate/phone/DebugHttpServer.kt +++ b/app-phone/src/main/kotlin/pw/binom/viewmate/phone/DebugHttpServer.kt @@ -121,9 +121,62 @@ object DebugHttpServer { // --- Mercury BLE (полевой тест либы rayneo-mercury) --- method == "GET" && path.startsWith("/mercury/status") -> { val s = MercuryBridge.state.value - response = """{"connected":${s.connected},"glasses":${s.glassesName?.let { "\"$it\"" } ?: "null"},"status":${jsonEscape(s.statusText)},"error":${s.lastError?.let { jsonEscape(it) } ?: "null"}}""" + response = """{"connected":${s.connected},"glasses":${s.glassesName?.let { "\"$it\"" } ?: "null"},"status":${jsonEscape(s.statusText)},"error":${s.lastError?.let { jsonEscape(it) } ?: "null"},"pairing":${s.pairCode != null},"pairCode":${s.pairCode?.let { "\"$it\"" } ?: "null"},"realAddress":${s.realAddress?.let { "\"$it\"" } ?: "null"}}""" code = 200 } + method == "GET" && path.startsWith("/mercury/bonded") -> { + val bonds = MercuryBridge.bondedAddresses() + val items = bonds.joinToString(",") { (addr, name) -> + """{"address":"$addr","name":${name?.let { jsonEscape(it) } ?: "null"}}""" + } + response = """{"count":${bonds.size},"bonds":[$items]}""" + code = 200 + } + method == "GET" && path.startsWith("/mercury/discover") -> { + val seconds = queryParams["time"]?.toIntOrNull() ?: 5 + val found = MercuryBridge.discoverGlasses(seconds * 1_000L) + val items = found.joinToString(",") { (name, addr) -> + """{"name":${name?.let { jsonEscape(it) } ?: "null"},"address":"$addr"}""" + } + val s = MercuryBridge.state.value + response = """{"count":${found.size},"found":[$items],"connected":${s.connected},"status":${jsonEscape(s.statusText)}}""" + code = 200 + } + method == "POST" && path.startsWith("/mercury/pair/stop") -> { + MercuryBridge.cancelPairing() + val s = MercuryBridge.state.value + response = """{"ok":true,"status":${jsonEscape(s.statusText)}}""" + code = 200 + } + method == "POST" && path.startsWith("/mercury/pair/classic") -> { + val address = queryParams["address"] + if (address.isNullOrBlank()) { + response = """{"error":"нет параметра address"}""" + code = 400 + } else { + val ok = MercuryBridge.pairClassic(address) + val s = MercuryBridge.state.value + response = """{"ok":$ok,"address":"$address","connected":${s.connected},"status":${jsonEscape(s.statusText)}}""" + code = 200 + } + } + method == "POST" && path.startsWith("/mercury/pair") -> { + val pairCode = MercuryBridge.startPairing() + val s = MercuryBridge.state.value + response = """{"ok":${pairCode != null},"pairCode":${pairCode?.let { "\"$it\"" } ?: "null"},"status":${jsonEscape(s.statusText)}}""" + code = 200 + } + method == "POST" && path.startsWith("/mercury/unpair") -> { + val address = queryParams["address"] + if (address.isNullOrBlank()) { + response = """{"error":"нет параметра address"}""" + code = 400 + } else { + val ok = MercuryBridge.unpair(address) + response = """{"ok":$ok,"address":"$address"}""" + code = 200 + } + } method == "POST" && path.startsWith("/mercury/connect") -> { val ok = kotlinx.coroutines.runBlocking { MercuryBridge.connectFirst() } response = """{"ok":$ok,"connected":${MercuryBridge.state.value.connected},"status":${jsonEscape(MercuryBridge.state.value.statusText)}}""" @@ -150,6 +203,18 @@ object DebugHttpServer { response = """{"ok":$ok,"status":${jsonEscape(MercuryBridge.state.value.statusText)}}""" code = 200 } + method == "POST" && path.startsWith("/mercury/wifi") -> { + val enable = "true".equals(queryParams["enable"], ignoreCase = true) + val ok = kotlinx.coroutines.runBlocking { MercuryBridge.wifiToggle(enable) } + response = """{"ok":$ok,"enable":$enable,"status":${jsonEscape(MercuryBridge.state.value.statusText)}}""" + code = 200 + } + method == "POST" && path.startsWith("/mercury/wifiscan") -> { + val ok = kotlinx.coroutines.runBlocking { MercuryBridge.wifiScan() } + val s = MercuryBridge.state.value + response = """{"ok":$ok,"networks":${s.wifiNetworks.size},"status":${jsonEscape(s.statusText)}}""" + code = 200 + } else -> { response = """{"error":"нет такого пути: $method $path"}""" code = 404 diff --git a/app-phone/src/main/kotlin/pw/binom/viewmate/phone/MainActivity.kt b/app-phone/src/main/kotlin/pw/binom/viewmate/phone/MainActivity.kt index 1609cf2..5e0cccb 100644 --- a/app-phone/src/main/kotlin/pw/binom/viewmate/phone/MainActivity.kt +++ b/app-phone/src/main/kotlin/pw/binom/viewmate/phone/MainActivity.kt @@ -5,6 +5,7 @@ import android.content.pm.PackageManager import android.graphics.Color import android.os.Bundle import android.view.Gravity +import android.view.WindowManager import android.widget.FrameLayout import android.widget.TextView import androidx.activity.ComponentActivity @@ -19,6 +20,7 @@ class MainActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) requestBtPermissions() + window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) setContent { ViewMateApp() } diff --git a/app-phone/src/main/kotlin/pw/binom/viewmate/phone/MercuryBridge.kt b/app-phone/src/main/kotlin/pw/binom/viewmate/phone/MercuryBridge.kt index 4991df5..b5b5d68 100644 --- a/app-phone/src/main/kotlin/pw/binom/viewmate/phone/MercuryBridge.kt +++ b/app-phone/src/main/kotlin/pw/binom/viewmate/phone/MercuryBridge.kt @@ -45,6 +45,10 @@ object MercuryBridge { val wifiEnabled: Boolean = false, val wifiSsid: String? = null, val wifiNetworks: List = emptyList(), + /** Активный pairCode для QR-паринга (не null, пока очки не подключились). */ + val pairCode: String? = null, + /** Реальный классический BT-адрес очков, полученный в pre-bond окне (null до паринга). */ + val realAddress: String? = null, ) private val _state = MutableStateFlow(UiState()) @@ -60,7 +64,7 @@ object MercuryBridge { mercury!!.events.collect { event -> when (event) { is RayNeoMercury.Event.Connected -> { - _state.value = _state.value.copy(connected = true) + _state.value = _state.value.copy(connected = true, pairCode = null) setStatus("подключено (BLE)") // Запрашиваем актуальный статус (яркость/WiFi/батарея), чтобы // ползунок яркости и переключатель WiFi показывали реальные значения. @@ -122,6 +126,28 @@ object MercuryBridge { _state.value = _state.value.copy(wifiNetworks = event.networks) log("wifi", "сетей: ${event.networks.size}") } + is RayNeoMercury.Event.RealBluetoothAddress -> { + // Pre-bond handshake: очки сообщили свой реальный классический + // BT-адрес (он же ожидает QR-pairCode). Создаём OS-level bond — + // это и есть «паринг» (в стоке: PairStateMachine → AutoClassic → createBond). + log("pair", "real bt address: ${event.address} name=${event.name} type=${event.type}") + _state.value = _state.value.copy(realAddress = event.address) + setStatus("получен адрес ${event.address}, настраиваю связь (bond)…") + scope.launch { + val m = mercury + val res = if (m != null) m.pairByRealAddress(event.address) + else PairManager.PairingResult.Failed("нет связи с библиотекой") + when (res) { + is PairManager.PairingResult.Success -> { + _state.value = _state.value.copy(connected = true, pairCode = null, glassesName = res.device.name) + setStatus("спаривание успешно: ${event.address}, подключаюсь…") + } + is PairManager.PairingResult.Failed -> { + setStatus("спаривание не удалось: ${res.reason}") + } + } + } + } } } } @@ -270,6 +296,96 @@ object MercuryBridge { return ok } + /** + * Старт паринга: перезапускает GATT-сервер с pairCode в рекламе и отдаёт код + * для QR. Пользователь показывает QR экрану очков — они его сканируют, + * подключаются в pre-bond окне и шлют свой реальный адрес + * (Event.RealBluetoothAddress), по которому мы делаем createBond. + */ + fun startPairing(): String? { + val m = mercury ?: return null + if (m.isAnyConnected) { + setStatus("очки уже подключены — паринг не нужен") + return null + } + val code = m.startPairing() + _state.value = _state.value.copy(pairCode = code) + setStatus("паринг: покажите QR экрану очков (код $code)") + return code + } + + /** Отмена активного QR-паринга (прячет QR, перезапускает сервер в обычном режиме без pairCode). */ + fun cancelPairing() { + _state.value = _state.value.copy(pairCode = null) + mercury?.stopPairing() + setStatus("паринг отменён") + } + + /** + * Забывает очки (removeBond). Применяется, когда очки на своей стороне + * потеряли бонд, а телефон держит «старый» бонд — рассинхрон блокирует + * нормальное повторное паринг. + */ + fun unpair(address: String): Boolean { + val m = mercury ?: return false + return kotlinx.coroutines.runBlocking { m.unpairAddress(address) } + } + + /** Текущие классические BT-бонды телефона (адрес → имя). Debug-помощник. */ + fun bondedAddresses(): List> = + mercury?.bondedAddresses() ?: emptyList() + + /** + * Классический паринг по известному классическому адресу — ручной путь без + * QR и камеры очков (то самое «подключите вручную в приложении» с экрана + * очков). createBond по адресу; очки после BONDED сами уходят из паринг-режима. + */ + fun pairClassic(address: String): Boolean { + val m = mercury ?: return false + val res = kotlinx.coroutines.runBlocking { m.pairByRealAddress(address) } + return if (res is PairManager.PairingResult.Success) { + _state.value = _state.value.copy(connected = true, pairCode = null, glassesName = res.device.name) + setStatus("классический паринг успешен: $address (${res.device.name})") + true + } else { + setError("классический паринг не удался: ${(res as? PairManager.PairingResult.Failed)?.reason}") + false + } + } + + /** + * Классический discovery: находит RayNeo-очки в режиме первого паринга вокруг + * телефона (без камеры). Возвращает список найденных (имя, адрес). + */ + fun discoverGlasses(timeoutMs: Long = 5_000): List> { + val m = mercury ?: return emptyList() + val found = kotlinx.coroutines.runBlocking { m.discoverClassic(timeoutMs) } + return found.map { it.device.name to it.address } + } + + /** + * Ручной паринг «в один клик»: классический discovery, выбор RayNeo-очков и + * createBond. Кнопка вместо QR — для случая, когда очки снова в режиме первого + * паринга (слетел бонд) и камера очков недоступна/не считывает QR. + */ + fun pairClassicAuto(timeoutMs: Long = 7_000): String? { + val found = discoverGlasses(timeoutMs) + if (found.isEmpty()) { + setError("ручной паринг: рядом не найдено ни одного классического устройства — включите очки") + return null + } + val target = found.firstOrNull { (it.first)?.contains("rayneo", ignoreCase = true) == true } + if (target == null) { + setError("ручной паринг: RayNeo рядом не найден (проверьте, что очки включены и в режиме паринга)") + return null + } + val address = target.second + val ok = pairClassic(address ?: return null) + if (!ok) return null + setStatus("ручной паринг: очки подключены ($address)") + return address + } + private fun setStatus(text: String) { log("status", text) _state.value = _state.value.copy(statusText = text, lastError = null) diff --git a/app-phone/src/main/kotlin/pw/binom/viewmate/phone/ui/MercuryPanel.kt b/app-phone/src/main/kotlin/pw/binom/viewmate/phone/ui/MercuryPanel.kt index 9ab2cb3..d86bd48 100644 --- a/app-phone/src/main/kotlin/pw/binom/viewmate/phone/ui/MercuryPanel.kt +++ b/app-phone/src/main/kotlin/pw/binom/viewmate/phone/ui/MercuryPanel.kt @@ -5,6 +5,7 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.Arrangement @@ -112,6 +113,66 @@ fun MercuryPanel() { } } + Row(modifier = Modifier.padding(top = 8.dp)) { + Button( + onClick = { MercuryBridge.startPairing() }, + enabled = !state.connected, + colors = ButtonDefaults.buttonColors( + containerColor = PhoneAccent, + contentColor = Color(0xFF10251A), + ), + ) { + Text("🔗 паринг (QR)", fontSize = 12.sp) + } + if (state.pairCode != null) { + Spacer(modifier = Modifier.width(8.dp)) + Button( + onClick = { MercuryBridge.cancelPairing() }, + colors = ButtonDefaults.buttonColors( + containerColor = PhoneSurface, + contentColor = PhoneMuted, + ), + ) { + Text("отмена", fontSize = 12.sp) + } + } + } + + Row(modifier = Modifier.padding(top = 8.dp)) { + Button( + onClick = { scope.launch { MercuryBridge.pairClassicAuto() } }, + enabled = !state.connected, + colors = ButtonDefaults.buttonColors( + containerColor = Color(0xFF3A3F47), + contentColor = Color(0xFFD5DBDF), + ), + ) { + Text("🔗 паринг (вручную)", fontSize = 12.sp) + } + } + + val pairCode = state.pairCode + if (pairCode != null) { + Column( + modifier = Modifier.fillMaxWidth().padding(top = 10.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text( + "Паринг: отсканируйте QR картинку камерой очков (она должна смотреть на экран телефона)", + color = PhoneMuted, + fontSize = 11.sp, + ) + QrCode(pairCode, side = 280, modifier = Modifier.padding(top = 8.dp)) + Spacer(modifier = Modifier.height(6.dp)) + Text( + "Код: $pairCode", + color = PhoneText, + fontSize = 11.sp, + fontWeight = FontWeight.SemiBold, + ) + } + } + Row(modifier = Modifier.padding(top = 8.dp)) { Button( onClick = { scope.launch { MercuryBridge.screenOff() } }, diff --git a/app-phone/src/main/kotlin/pw/binom/viewmate/phone/ui/QrCode.kt b/app-phone/src/main/kotlin/pw/binom/viewmate/phone/ui/QrCode.kt new file mode 100644 index 0000000..eb94181 --- /dev/null +++ b/app-phone/src/main/kotlin/pw/binom/viewmate/phone/ui/QrCode.kt @@ -0,0 +1,58 @@ +package pw.binom.viewmate.phone.ui + +import android.graphics.Bitmap +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.size +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.unit.dp +import com.google.zxing.BarcodeFormat +import com.google.zxing.qrcode.QRCodeWriter + +/** + * Рендер QR-кода (pairCode) для паринга очков: очки сканируют его своей камерой. + * В стоковом приложении RayNeo QR кодирует ровно pairCode (8 символов). + */ +@Composable +fun QrCode( + text: String, + modifier: Modifier = Modifier, + side: Int = 200, + foreground: Color = Color.Black, + background: Color = Color.White, +) { + val density = LocalDensity.current.density + val sizePx = (side * density).toInt().coerceAtLeast(256) + val bitmap = remember(text, sizePx, foreground, background) { + renderQr( + text = text, + sizePx = sizePx, + foreground = foreground.toArgb(), + background = background.toArgb(), + ) + } + Image( + bitmap = bitmap.asImageBitmap(), + contentDescription = "QR-код паринга", + modifier = modifier.size(side.dp), + ) +} + +private fun renderQr(text: String, sizePx: Int, foreground: Int, background: Int): Bitmap { + val bits = QRCodeWriter().encode(text, BarcodeFormat.QR_CODE, sizePx, sizePx) + val w = bits.width + val h = bits.height + val pixels = IntArray(w * h) + for (y in 0 until h) { + val rowOffset = y * w + for (x in 0 until w) { + pixels[rowOffset + x] = if (bits.get(x, y)) foreground else background + } + } + return Bitmap.createBitmap(pixels, w, h, Bitmap.Config.ARGB_8888) +} \ No newline at end of file