From d674b31a8153f8d8bbe3a4bde69863294835010b Mon Sep 17 00:00:00 2001 From: Hermes Agent Date: Mon, 24 Aug 2026 23:19:37 +0300 Subject: [PATCH] =?UTF-8?q?=D0=92=D0=BD=D0=B5=D0=B4=D1=80=D0=B5=D0=BD?= =?UTF-8?q?=D0=B8=D0=B5=20rayneo-mercury:=20=D0=B7=D0=B0=D0=B2=D0=B8=D1=81?= =?UTF-8?q?=D0=B8=D0=BC=D0=BE=D1=81=D1=82=D1=8C=20+=20MercuryPanel=20(BLE-?= =?UTF-8?q?=D1=82=D0=B5=D1=81=D1=82:=20=D0=BF=D0=BE=D0=B3=D0=B0=D1=81?= =?UTF-8?q?=D0=B8=D1=82=D1=8C/=D0=B2=D0=BA=D0=BB=D1=8E=D1=87=D0=B8=D1=82?= =?UTF-8?q?=D1=8C=20=D1=8D=D0=BA=D1=80=D0=B0=D0=BD)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app-phone/build.gradle.kts | 3 + .../pw/binom/viewmate/phone/MercuryBridge.kt | 132 ++++++++++++++++++ .../pw/binom/viewmate/phone/PhoneApp.kt | 2 + .../binom/viewmate/phone/ui/GlassesScreen.kt | 3 + .../binom/viewmate/phone/ui/MercuryPanel.kt | 124 ++++++++++++++++ settings.gradle.kts | 4 + 6 files changed, 268 insertions(+) create mode 100644 app-phone/src/main/kotlin/pw/binom/viewmate/phone/MercuryBridge.kt create mode 100644 app-phone/src/main/kotlin/pw/binom/viewmate/phone/ui/MercuryPanel.kt diff --git a/app-phone/build.gradle.kts b/app-phone/build.gradle.kts index c4e9687..019f665 100644 --- a/app-phone/build.gradle.kts +++ b/app-phone/build.gradle.kts @@ -94,6 +94,9 @@ dependencies { implementation(libs.kotlinx.serialization.json) implementation(project(":lib-core")) + // RayNeo Mercury BLE protocol (RayNeo X2): pair/connect/команды с телефона + implementation("pw.binom.mercury:rayneo-mercury:0.1.0-SNAPSHOT") + // Koog (JetBrains) — LLM-клиент (OpenAI-совместимый) implementation(libs.koog.openai.client) implementation(libs.koog.http.client.okhttp) 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 new file mode 100644 index 0000000..d96db9b --- /dev/null +++ b/app-phone/src/main/kotlin/pw/binom/viewmate/phone/MercuryBridge.kt @@ -0,0 +1,132 @@ +package pw.binom.viewmate.phone + +import android.Manifest +import android.content.Context +import android.content.pm.PackageManager +import android.util.Log +import androidx.core.content.ContextCompat +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import pw.binom.mercury.DiscoveredGlasses +import pw.binom.mercury.RayNeoMercury + +/** + * Мост к либе rayneo-mercury (протокол RayNeo Mercury по BLE). + * + * Минимум для полевого теста: скан → pair → connect → погасить/включить экран + * (deepSuspend). События либы логируются и частично проксируются в [status]. + * + * Кнопки экрана — только проверка транспорта: если очки принимают команду, + * протокол жив, дальше можно вешать остальные команды (яркость, WiFi, магазин). + */ +object MercuryBridge { + + private const val TAG = "MercuryBridge" + + private var mercury: RayNeoMercury? = null + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + + data class UiState( + val connected: Boolean = false, + val glassesName: String? = null, + val statusText: String = "не подключено", + val lastError: String? = null, + ) + + private val _state = MutableStateFlow(UiState()) + val state: StateFlow = _state.asStateFlow() + + private var currentGlasses: DiscoveredGlasses? = null + + /** Инициализация (вызывать из Application). */ + fun init(context: Context) { + if (mercury != null) return + mercury = RayNeoMercury(context.applicationContext, scope) + scope.launch { + mercury!!.events.collect { event -> + when (event) { + is RayNeoMercury.Event.Connected -> setStatus("подключено (BLE)") + is RayNeoMercury.Event.Disconnected -> setStatus("отключено: ${event.cause}") + is RayNeoMercury.Event.JsonMessage -> log("json", "${event.uri} ${event.json.take(200)}") + is RayNeoMercury.Event.StreamPacket -> log("stream", "packet seq=${event.seq} type=${event.type} ${event.payload.size}B") + is RayNeoMercury.Event.AppInstallStatus -> setStatus("app: ${event.pkg} status=${event.status} err=${event.errCode}") + is RayNeoMercury.Event.AppInstallProgress -> log("app", "${event.pkg}: ${event.progress}%") + is RayNeoMercury.Event.WirelessAdbAddress -> setStatus("ADB: ${event.address}") + is RayNeoMercury.Event.LogcatLine -> log("logcat", event.line) + is RayNeoMercury.Event.GeneralStatusUpdate -> log("status", "brightness=${event.status.brightness} deepSuspend=${event.status.deepSuspend}") + is RayNeoMercury.Event.WifiScanResult -> log("wifi", "сетей: ${event.networks.size}") + } + } + } + } + + /** Прав на BLE нет — вернуть false; хост-приложение запрашивает само. */ + fun hasBlePermissions(context: Context): Boolean { + val m = mercury ?: return false + return m.hasPermissions() && + ContextCompat.checkSelfPermission(context, Manifest.permission.BLUETOOTH_SCAN) == PackageManager.PERMISSION_GRANTED && + ContextCompat.checkSelfPermission(context, Manifest.permission.BLUETOOTH_CONNECT) == PackageManager.PERMISSION_GRANTED + } + + /** Скан + pair (если нужно) + connect. Возвращает true при успехе. */ + suspend fun connectFirst(timeoutMillis: Long = 20_000): Boolean { + val m = mercury ?: return false + setStatus("сканирую очки…") + val glasses = m.scanOnce(timeoutMillis) + if (glasses == null) { + setError("очки не найдены (скан $timeoutMillis мс)") + return false + } + currentGlasses = glasses + setStatus("найдены: ${glasses.name ?: glasses.address}") + + val pairResult = m.pair(glasses) + if (pairResult is pw.binom.mercury.pair.PairManager.PairingResult.Failed) { + log("pair", "failed: ${pairResult.reason}") + } + + val ok = m.connect(glasses) + if (!ok) setError("connect не удался") + _state.value = _state.value.copy(connected = ok, glassesName = glasses.name) + return ok + } + + suspend fun disconnect() { + mercury?.disconnect() + _state.value = _state.value.copy(connected = false) + setStatus("отключено") + } + + /** Погасить экран очков (deepSuspend=1). */ + suspend fun screenOff(): Boolean { + val ok = mercury?.setDeepSuspend(true) ?: false + setStatus(if (ok) "команда «погасить экран» отправлена" else "ошибка отправки (нет связи?)") + return ok + } + + /** Включить экран очков (deepSuspend=0). */ + suspend fun screenOn(): Boolean { + val ok = mercury?.setDeepSuspend(false) ?: false + setStatus(if (ok) "команда «включить экран» отправлена" else "ошибка отправки (нет связи?)") + return ok + } + + private fun setStatus(text: String) { + log("status", text) + _state.value = _state.value.copy(statusText = text, lastError = null) + } + + private fun setError(text: String) { + Log.e(TAG, text) + _state.value = _state.value.copy(statusText = text, lastError = text) + } + + private fun log(tag: String, msg: String) { + Log.d(TAG, "[$tag] $msg") + } +} diff --git a/app-phone/src/main/kotlin/pw/binom/viewmate/phone/PhoneApp.kt b/app-phone/src/main/kotlin/pw/binom/viewmate/phone/PhoneApp.kt index db476a4..bb7e76e 100644 --- a/app-phone/src/main/kotlin/pw/binom/viewmate/phone/PhoneApp.kt +++ b/app-phone/src/main/kotlin/pw/binom/viewmate/phone/PhoneApp.kt @@ -425,6 +425,8 @@ class PhoneApp : Application() { PlaybackService.start(this) log("server", "playback-сервис запущен (foreground, WAKE_LOCK)") + MercuryBridge.init(this) + server.start() log("server", "WS-сервер запущен на ${PhoneConfig.SERVER_PORT}") DebugHttpServer.start(this) diff --git a/app-phone/src/main/kotlin/pw/binom/viewmate/phone/ui/GlassesScreen.kt b/app-phone/src/main/kotlin/pw/binom/viewmate/phone/ui/GlassesScreen.kt index ca02e94..10ac5ea 100644 --- a/app-phone/src/main/kotlin/pw/binom/viewmate/phone/ui/GlassesScreen.kt +++ b/app-phone/src/main/kotlin/pw/binom/viewmate/phone/ui/GlassesScreen.kt @@ -360,6 +360,9 @@ private fun GlassesStatusCard( fg = if (playing) PhoneBlueText else PhoneMuted, ) } + + // Полевое испытание либы rayneo-mercury (BLE-протокол очков) + MercuryPanel() } } 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 new file mode 100644 index 0000000..fe622e4 --- /dev/null +++ b/app-phone/src/main/kotlin/pw/binom/viewmate/phone/ui/MercuryPanel.kt @@ -0,0 +1,124 @@ +package pw.binom.viewmate.phone.ui + +import androidx.compose.foundation.background +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.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import kotlinx.coroutines.launch +import pw.binom.viewmate.phone.MercuryBridge + +/** + * Панель Mercury (BLE-протокол RayNeo X2) — полевое испытание либы rayneo-mercury. + * Минимум: найти очки → подключить → погасить/включить экран (deepSuspend). + */ +@Composable +fun MercuryPanel() { + val scope = rememberCoroutineScope() + val state by MercuryBridge.state.collectAsState() + val busy = false + + Column( + modifier = Modifier + .fillMaxWidth() + .padding(top = 16.dp) + .clip(RoundedCornerShape(14.dp)) + .background(PhoneCard) + .padding(12.dp), + ) { + Text( + text = "🛰 Mercury BLE (полевой тест)", + color = PhoneText, + fontSize = 14.sp, + fontWeight = FontWeight.SemiBold, + ) + Text( + text = state.statusText, + color = if (state.lastError != null) PhoneRedText else PhoneMuted, + fontSize = 11.sp, + modifier = Modifier.padding(top = 4.dp), + ) + if (state.connected) { + Text( + text = "очки: ${state.glassesName ?: "?"} · BLE готов", + color = PhoneBlueText, + fontSize = 11.sp, + modifier = Modifier.padding(top = 2.dp), + ) + } + + Row(modifier = Modifier.padding(top = 10.dp)) { + Button( + onClick = { + scope.launch { + MercuryBridge.connectFirst() + } + }, + enabled = !busy, + colors = ButtonDefaults.buttonColors( + containerColor = PhoneBlue, + contentColor = PhoneBlueText, + ), + ) { + Text("подключить очки", fontSize = 12.sp) + } + Spacer(modifier = Modifier.width(8.dp)) + Button( + onClick = { + scope.launch { MercuryBridge.disconnect() } + }, + enabled = state.connected, + colors = ButtonDefaults.buttonColors( + containerColor = PhoneSurface, + contentColor = PhoneMuted, + ), + ) { + Text("отключить", fontSize = 12.sp) + } + } + + Row(modifier = Modifier.padding(top = 8.dp)) { + Button( + onClick = { + scope.launch { MercuryBridge.screenOff() } + }, + enabled = state.connected, + colors = ButtonDefaults.buttonColors( + containerColor = Color(0xFF2B2F36), + contentColor = Color(0xFF9AA0A6), + ), + ) { + Text("🌙 погасить экран", fontSize = 12.sp) + } + Spacer(modifier = Modifier.width(8.dp)) + Button( + onClick = { + scope.launch { MercuryBridge.screenOn() } + }, + enabled = state.connected, + colors = ButtonDefaults.buttonColors( + containerColor = PhoneAccent, + contentColor = Color(0xFF10251A), + ), + ) { + Text("☀️ включить экран", fontSize = 12.sp) + } + } + } +} diff --git a/settings.gradle.kts b/settings.gradle.kts index 2c6d5fe..dd0764e 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -16,6 +16,10 @@ dependencyResolutionManagement { maven(url = "https://jitpack.io") mavenCentral() google() + // Домашний Nexus: личные Android/KMP-библиотеки (rayneo-mercury и др.) + maven(url = "http://192.168.76.117/repository/caffeine/") { + isAllowInsecureProtocol = true + } } }