Пейринг RayNeo: кнопка ручного (без QR) ре-паринга + debug-роуты + keep-screen-on
- 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/состояние реально были на экране телефона
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -45,6 +45,10 @@ object MercuryBridge {
|
||||
val wifiEnabled: Boolean = false,
|
||||
val wifiSsid: String? = null,
|
||||
val wifiNetworks: List<MyWifiInfo> = 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<Pair<String, String?>> =
|
||||
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<Pair<String, String?>> {
|
||||
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)
|
||||
|
||||
@@ -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() } },
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user