glasses: hold Mercury kill switch on hidden VirtualDisplay; phone: brightness/WiFi panel, two-finger touchpad scroll

This commit is contained in:
2026-09-25 19:49:25 +03:00
parent 3fce3eb3ca
commit c69e8a2e80
8 changed files with 544 additions and 26 deletions
@@ -12,12 +12,15 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.delay
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
import pw.binom.mercury.model.GeneralStatus
import pw.binom.mercury.model.MyWifiInfo
/**
* Контроллер второго канала связи с очками — RayNeo Mercury BLE.
@@ -40,6 +43,21 @@ class MercuryController(private val context: Context) {
private var lastScanResults: List<DiscoveredGlasses> = emptyList()
private val _generalStatus = MutableStateFlow<GeneralStatus?>(null)
/** Последний известный статус очков: яркость, WiFi, экран, батарея. */
val generalStatus: StateFlow<GeneralStatus?> = _generalStatus.asStateFlow()
private val _wifiNetworks = MutableStateFlow<List<MyWifiInfo>>(emptyList())
/** Сети, найденные последним сканом WiFi на очках. */
val wifiNetworks: StateFlow<List<MyWifiInfo>> = _wifiNetworks.asStateFlow()
private val _wifiScanning = MutableStateFlow(false)
/** Идёт ли сейчас скан WiFi-сетей очков. */
val wifiScanning: StateFlow<Boolean> = _wifiScanning.asStateFlow()
/** Разрешения, которые нужно запросить для работы с BLE на этом API. */
val requiredPermissions: Array<String> = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
arrayOf(
@@ -156,6 +174,7 @@ class MercuryController(private val context: Context) {
fun disconnect() {
scope.launch { mercury.disconnect() }
clearGlassesData()
_state.value = MercuryState.Disconnected()
}
@@ -171,6 +190,81 @@ class MercuryController(private val context: Context) {
/** Доступ к raw event-flow библиотеки (debug/диагностика). */
val events get() = mercury.events
// ------------------------------------------------------------------ экран / яркость / wifi
/** Яркость экрана очков, 0..100. */
fun setBrightness(value: Int) = launchMercury("setBrightness") {
mercury.setBrightness(value)
}
/** Авто-яркость экрана очков. */
fun setAutoBrightness(enable: Boolean) = launchMercury("setAutoBrightness") {
mercury.setAutoBrightness(enable)
}
/**
* Погасить (on=false, deepSuspend) или разбудить (on=true) экран очков.
*
* Перед засыпанием включаем heartbeat: без него очки роняют BLE-связь
* примерно через 3с после выключения экрана (см. RayNeoMercury.enableHeartbeat).
*/
fun setScreenOn(on: Boolean) = launchMercury("setScreenOn($on)") {
if (!on) mercury.enableHeartbeat()
mercury.setDeepSuspend(!on)
}
/** Включить/выключить WiFi-радио очков. */
fun setWifiEnabled(enable: Boolean) = launchMercury("setWifiEnabled($enable)") {
mercury.wifiToggle(enable)
}
/** Просканировать WiFi-сети очками; результат придёт в [wifiNetworks]. */
fun scanWifi() {
_wifiNetworks.value = emptyList()
_wifiScanning.value = true
scope.launch {
val ok = try {
mercury.wifiScan()
} catch (t: Throwable) {
android.util.Log.w(TAG, "wifiScan failed: ${t.message}")
false
}
if (!ok) _wifiScanning.value = false
}
// Очки не всегда присылают ответ — снимаем «сканируем…» по таймауту.
scope.launch {
delay(WIFI_SCAN_TIMEOUT_MS)
_wifiScanning.value = false
}
}
/** Подключить очки к WiFi-сети ([password] = null для открытой). */
fun connectWifi(ssid: String, password: String?) = launchMercury("connectWifi($ssid)") {
mercury.wifiConnect(ssid, password)
}
/** Забыть сохранённую сеть на очках. */
fun forgetWifi(ssid: String) = launchMercury("forgetWifi($ssid)") {
mercury.wifiRemove(ssid)
}
private fun launchMercury(action: String, block: suspend () -> Unit) {
scope.launch {
try {
block()
} catch (t: Throwable) {
android.util.Log.w(TAG, "$action failed: ${t.message}")
}
}
}
/** Сбрасывает данные, которые живут только внутри BLE-сессии. */
private fun clearGlassesData() {
_generalStatus.value = null
_wifiNetworks.value = emptyList()
_wifiScanning.value = false
}
private fun refreshPermissionState() {
_state.value = when {
!hasPermissions() -> MercuryState.PermissionsMissing
@@ -205,12 +299,14 @@ class MercuryController(private val context: Context) {
is RayNeoMercury.Event.Disconnected -> {
val cur = _state.value
if (cur is MercuryState.Connected) {
clearGlassesData()
_state.value = MercuryState.Disconnected(
message = "Связь потеряна: ${event.cause}",
)
}
}
is RayNeoMercury.Event.GeneralStatusUpdate -> {
_generalStatus.value = event.status
val cur = _state.value
if (cur is MercuryState.Connected) {
_state.value = cur.copy(
@@ -218,6 +314,10 @@ class MercuryController(private val context: Context) {
)
}
}
is RayNeoMercury.Event.WifiScanResult -> {
_wifiNetworks.value = event.networks
_wifiScanning.value = false
}
else -> Unit
}
}
@@ -230,6 +330,11 @@ class MercuryController(private val context: Context) {
}
companion object {
private const val TAG = "MercuryController"
/** Сколько ждать ответ скана WiFi, прежде чем снять статус «сканируем…». */
private const val WIFI_SCAN_TIMEOUT_MS = 15_000L
val Local = staticCompositionLocalOf<MercuryController> {
error("MercuryController not provided")
}
@@ -20,6 +20,7 @@ import pw.binom.rayneovm.shared.AppListResponse
import pw.binom.rayneovm.shared.AppLaunchedAck
import pw.binom.rayneovm.shared.CursorClick
import pw.binom.rayneovm.shared.CursorMove
import pw.binom.rayneovm.shared.CursorScroll
import pw.binom.rayneovm.shared.GetAppList
import pw.binom.rayneovm.shared.GlassesStatus
import pw.binom.rayneovm.shared.KeyEvent as VmKeyEvent
@@ -75,8 +76,9 @@ class VmPhoneController(
_state.value = ConnectionState.Searching
val scanner = pw.binom.mercury.transport.MdnsScanner(context, "_rayneo-vm._tcp")
// mDNS пытается 5с; если не вышло — TCP-фоллбек по подсети ещё ~5с.
// Порт 8080 (см. docs/SPEC.md:4752 и VmService.WS_PORT).
val service = scanner.discoverOnce(timeoutMs = 10_000, fallbackPort = 8080)
// Порт 8080 и путь /vm (см. VmService.WS_PORT/WS_PATH). Путь нужен фоллбеку,
// чтобы отличить наш сервер от чужого на том же порту.
val service = scanner.discoverOnce(timeoutMs = 10_000, fallbackPort = 8080, fallbackPath = "/vm")
_state.value = ConnectionState.Connecting(service.host)
val url = "ws://${service.host}:${service.port}/vm"
val transport = Transports.wifi(url, autoReconnect = false)
@@ -168,6 +170,9 @@ class VmPhoneController(
/** Клик мыши в текущей позиции курсора на очках. */
suspend fun clickCursor() = send(CursorClick)
/** Скролл в текущей позиции курсора: dx/dy — смещение пальцев в пикселях гостя. */
suspend fun scrollCursor(dx: Float, dy: Float) = send(CursorScroll(dx, dy))
/** Клавиша/медиа-команда гостю (Back, Play, Pause, ...): DOWN + UP. */
suspend fun sendKey(keyCode: Int) {
send(VmKeyEvent(keyCode, VmKeyEvent.ACTION_DOWN))
@@ -12,6 +12,7 @@ import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Card
@@ -19,19 +20,29 @@ import androidx.compose.material3.CardDefaults
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Slider
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import kotlinx.coroutines.launch
import pw.binom.mercury.DiscoveredGlasses
import pw.binom.mercury.model.GeneralStatus
import pw.binom.mercury.model.MyWifiInfo
import pw.binom.rayneovm.phone.mercury.MercuryController
import pw.binom.rayneovm.phone.mercury.MercuryState
import pw.binom.rayneovm.phone.protocol.ConnectionState
@@ -58,6 +69,9 @@ fun GlassesStatusScreen(
val connection by controller.state.collectAsState()
val glasses by controller.glassesStatus.collectAsState()
val mercury by mercuryController.state.collectAsState()
val generalStatus by mercuryController.generalStatus.collectAsState()
val wifiNetworks by mercuryController.wifiNetworks.collectAsState()
val wifiScanning by mercuryController.wifiScanning.collectAsState()
val scope = rememberCoroutineScope()
val mercuryConnected = mercury is MercuryState.Connected
@@ -89,6 +103,24 @@ fun GlassesStatusScreen(
onDisconnect = { mercuryController.disconnect() },
)
ScreenAndBrightnessSection(
status = generalStatus,
enabled = mercuryConnected,
onBrightness = { mercuryController.setBrightness(it) },
onAutoBrightness = { mercuryController.setAutoBrightness(it) },
onScreenOn = { mercuryController.setScreenOn(it) },
)
GlassesWifiSection(
status = generalStatus,
networks = wifiNetworks,
scanning = wifiScanning,
enabled = mercuryConnected,
onToggle = { mercuryController.setWifiEnabled(it) },
onScan = { mercuryController.scanWifi() },
onConnect = { ssid, password -> mercuryController.connectWifi(ssid, password) },
)
Spacer(modifier = Modifier.height(8.dp))
LaunchGlassesAppButton(
@@ -185,6 +217,163 @@ private fun Channel2MercurySection(
}
}
@Composable
private fun ScreenAndBrightnessSection(
status: GeneralStatus?,
enabled: Boolean,
onBrightness: (Int) -> Unit,
onAutoBrightness: (Boolean) -> Unit,
onScreenOn: (Boolean) -> Unit,
) {
SectionCard(title = "Экран и яркость") {
if (!enabled) {
Hint("Нужен Канал 2 (Mercury BLE).")
} else {
// Пока палец на слайдере — показываем локальное значение,
// а в очки отправляем один раз, на отпускании (BLE не любит спам).
var drag by remember { mutableStateOf<Float?>(null) }
val brightness = drag ?: (status?.brightness ?: 0).toFloat()
StatusRow("Яркость", "${brightness.toInt()}%")
Slider(
value = brightness,
onValueChange = { drag = it },
onValueChangeFinished = {
drag?.let { onBrightness(it.toInt()) }
drag = null
},
valueRange = 0f..100f,
)
ToggleRow(
label = "Авто-яркость",
checked = status?.automaticBrightness == true,
onCheckedChange = onAutoBrightness,
)
val sleeping = (status?.deepSuspend ?: 0) == 1
OutlinedButton(onClick = { onScreenOn(sleeping) }) {
Text(if (sleeping) "Разбудить экран" else "Погасить экран")
}
}
}
}
@Composable
private fun GlassesWifiSection(
status: GeneralStatus?,
networks: List<MyWifiInfo>,
scanning: Boolean,
enabled: Boolean,
onToggle: (Boolean) -> Unit,
onScan: () -> Unit,
onConnect: (String, String?) -> Unit,
) {
var pendingNetwork by remember { mutableStateOf<MyWifiInfo?>(null) }
SectionCard(title = "WiFi очков") {
if (!enabled) {
Hint("Нужен Канал 2 (Mercury BLE).")
} else {
ToggleRow(
label = "WiFi включён",
checked = status?.wifiEnabled == true,
onCheckedChange = onToggle,
)
StatusRow("Сеть", status?.wifiInfo?.ssid ?: "не подключено")
Spacer(modifier = Modifier.height(4.dp))
OutlinedButton(onClick = onScan, enabled = !scanning) {
Text(if (scanning) "Сканируем…" else "Найти сети")
}
if (networks.isNotEmpty()) {
Text("Найденные сети:", color = Color(0xFFB0B0B0), fontSize = 12.sp)
networks.forEach { network ->
NetworkRow(
network = network,
onClick = {
if (network.needsPassword()) {
pendingNetwork = network
} else {
onConnect(network.ssid, null)
}
},
)
}
}
}
}
pendingNetwork?.let { network ->
WifiPasswordDialog(
ssid = network.ssid,
onDismiss = { pendingNetwork = null },
onConfirm = { password ->
onConnect(network.ssid, password)
pendingNetwork = null
},
)
}
}
@Composable
private fun NetworkRow(network: MyWifiInfo, onClick: () -> Unit) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = if (network.saved) "${network.ssid} (сохранена)" else network.ssid,
color = Color.White,
fontSize = 14.sp,
)
if (network.needsPassword()) {
Text("нужен пароль", color = Color(0xFFB0B0B0), fontSize = 11.sp)
}
}
Button(onClick = onClick) { Text("Подключить") }
}
}
@Composable
private fun WifiPasswordDialog(ssid: String, onDismiss: () -> Unit, onConfirm: (String) -> Unit) {
var password by remember { mutableStateOf("") }
AlertDialog(
onDismissRequest = onDismiss,
title = { Text("Пароль WiFi") },
text = {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text(ssid, color = Color(0xFFB0B0B0), fontSize = 13.sp)
OutlinedTextField(
value = password,
onValueChange = { password = it },
singleLine = true,
label = { Text("Пароль") },
visualTransformation = PasswordVisualTransformation(),
)
}
},
confirmButton = {
TextButton(onClick = { onConfirm(password) }) { Text("Подключить") }
},
dismissButton = {
TextButton(onClick = onDismiss) { Text("Отмена") }
},
)
}
@Composable
private fun ToggleRow(label: String, checked: Boolean, onCheckedChange: (Boolean) -> Unit) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
) {
Text(text = label, color = Color(0xFFB0B0B0), fontSize = 14.sp)
Switch(checked = checked, onCheckedChange = onCheckedChange)
}
}
@Composable
private fun LaunchGlassesAppButton(enabled: Boolean, onClick: () -> Unit) {
Button(
@@ -2,8 +2,8 @@ package pw.binom.rayneovm.phone.ui
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background
import androidx.compose.foundation.gestures.detectDragGestures
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.gestures.awaitEachGesture
import androidx.compose.foundation.gestures.awaitFirstDown
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
@@ -48,8 +48,9 @@ private const val KEYCODE_MEDIA_PAUSE = 127
/**
* Пульт управления гостевым приложением на очках:
* - тачпад-мышь: движение пальца → курсор (отыгрывается на очках в обоих глазах),
* - тачпад-мышь: движение одного пальца → курсор (отыгрывается на очках в обоих глазах),
* тап по тачпаду → клик в текущей позиции курсора;
* - два пальца по тачпаду → скролл (вверх/вниз/вправо/влево) под курсором;
* - кнопки «Назад», «Плей», «Пауза»;
* - «Клавиатура» — заглушка (сделаем позже).
*/
@@ -77,25 +78,61 @@ fun RemoteScreen(
.clip(RoundedCornerShape(20.dp))
.background(Color(0xFF141414))
.pointerInput(Unit) {
detectTapGestures(onTap = { click() })
}
.pointerInput(Unit) {
detectDragGestures { change, drag ->
// Относительное движение: полный проход по тачпаду = весь экран гостя.
// Один палец — курсор; два пальца — скролл (как на тачпаде ноутбука).
awaitEachGesture {
val gainX = VmDisplay.WIDTH / size.width.toFloat()
val gainY = VmDisplay.HEIGHT / size.height.toFloat()
cursor = Offset(
(cursor.x + drag.x * gainX).coerceIn(0f, VmDisplay.WIDTH - 1f),
(cursor.y + drag.y * gainY).coerceIn(0f, VmDisplay.HEIGHT - 1f),
)
change.consume()
scope.launch { controller.moveCursor(cursor.x, cursor.y) }
var mode = PadMode.UNDECIDED
var lastCentroid = Offset.Zero
var travel = 0f
awaitFirstDown(requireUnconsumed = false)
while (true) {
val event = awaitPointerEvent()
val pressed = event.changes.filter { it.pressed }
if (pressed.isEmpty()) break
val centroid = pressed.fold(Offset.Zero) { acc, c -> acc + c.position } /
pressed.size.toFloat()
// Второй палец появился (или жест начался сразу с двух) — скроллим.
if (mode == PadMode.UNDECIDED || (mode == PadMode.CURSOR && pressed.size >= 2)) {
mode = if (pressed.size >= 2) PadMode.SCROLL else PadMode.CURSOR
lastCentroid = centroid
continue
}
val delta = centroid - lastCentroid
lastCentroid = centroid
if (delta.getDistance() <= 0f) continue
travel += delta.getDistance()
when (mode) {
PadMode.CURSOR -> {
// Относительное движение: полный проход по тачпаду = весь экран гостя.
cursor = Offset(
(cursor.x + delta.x * gainX).coerceIn(0f, VmDisplay.WIDTH - 1f),
(cursor.y + delta.y * gainY).coerceIn(0f, VmDisplay.HEIGHT - 1f),
)
scope.launch { controller.moveCursor(cursor.x, cursor.y) }
}
PadMode.SCROLL -> {
// Смещение пальцев в пикселях гостя; очки инжектят колесо.
scope.launch {
controller.scrollCursor(delta.x * gainX, delta.y * gainY)
}
}
PadMode.UNDECIDED -> Unit
}
event.changes.forEach { it.consume() }
}
// Отпустили, не сдвинув палец, — это тап (клик по курсору).
if (mode != PadMode.SCROLL && travel < viewConfiguration.touchSlop) click()
}
},
contentAlignment = Alignment.Center,
) {
Text(
text = "Тачпад: ведите пальцем — курсор на очках.\nТап — клик по курсору.",
text = "Тачпад: один палец — курсор на очках, тап — клик.\nДва пальца — скролл.",
color = Color(0xFF666666),
fontSize = 13.sp,
textAlign = TextAlign.Center,
@@ -147,3 +184,6 @@ private fun RemoteButton(
}
}
}
/** Режим жеста на тачпаде — определяется по количеству пальцев. */
private enum class PadMode { UNDECIDED, CURSOR, SCROLL }