Внедрение rayneo-mercury: зависимость + MercuryPanel (BLE-тест: погасить/включить экран)
This commit is contained in:
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user