Внедрение rayneo-mercury: зависимость + MercuryPanel (BLE-тест: погасить/включить экран)

This commit is contained in:
2026-08-24 23:19:37 +03:00
parent c222a4ac76
commit d674b31a81
6 changed files with 268 additions and 0 deletions
+3
View File
@@ -94,6 +94,9 @@ dependencies {
implementation(libs.kotlinx.serialization.json) implementation(libs.kotlinx.serialization.json)
implementation(project(":lib-core")) 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-совместимый) // Koog (JetBrains) — LLM-клиент (OpenAI-совместимый)
implementation(libs.koog.openai.client) implementation(libs.koog.openai.client)
implementation(libs.koog.http.client.okhttp) implementation(libs.koog.http.client.okhttp)
@@ -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<UiState> = _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")
}
}
@@ -425,6 +425,8 @@ class PhoneApp : Application() {
PlaybackService.start(this) PlaybackService.start(this)
log("server", "playback-сервис запущен (foreground, WAKE_LOCK)") log("server", "playback-сервис запущен (foreground, WAKE_LOCK)")
MercuryBridge.init(this)
server.start() server.start()
log("server", "WS-сервер запущен на ${PhoneConfig.SERVER_PORT}") log("server", "WS-сервер запущен на ${PhoneConfig.SERVER_PORT}")
DebugHttpServer.start(this) DebugHttpServer.start(this)
@@ -360,6 +360,9 @@ private fun GlassesStatusCard(
fg = if (playing) PhoneBlueText else PhoneMuted, fg = if (playing) PhoneBlueText else PhoneMuted,
) )
} }
// Полевое испытание либы rayneo-mercury (BLE-протокол очков)
MercuryPanel()
} }
} }
@@ -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)
}
}
}
}
+4
View File
@@ -16,6 +16,10 @@ dependencyResolutionManagement {
maven(url = "https://jitpack.io") maven(url = "https://jitpack.io")
mavenCentral() mavenCentral()
google() google()
// Домашний Nexus: личные Android/KMP-библиотеки (rayneo-mercury и др.)
maven(url = "http://192.168.76.117/repository/caffeine/") {
isAllowInsecureProtocol = true
}
} }
} }