Files
view-mate/app-glasses/src/main/kotlin/pw/binom/viewmate/glasses/HostConnection.kt
T
Hermes Agent a434d5bb12 feat: потоковый локальный ассистент — фраза → LLM → ответ на очки
- agent/Assistant.kt: оркестратор диалога (SQLite-история + Koog), таймаут 30с,
  сессия одна живая, история — последние 20 сообщений
- ChatDao.latestSessionId(); SYSTEM_PROMPT нейтральный (TODO: персона)
- PhoneApp: очередь фраз (Channel UNLIMITED), assistantLoop (THINKING/ShowText/IDLE),
  ensureAssistant ленивый; ключ/модель из BuildConfig (local.properties)
- Очки: assistantText StateFlow, ShowText → оверлей (белый текст речи + серый ответ),
  оверлей остаётся после Click до новой сессии
- AssistantTest (androidTest, 3 теста: сессия/история/таймаут) — OK (6 tests всего)
- README: строка про ассистента
2026-08-20 12:35:49 +03:00

309 lines
13 KiB
Kotlin
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package pw.binom.viewmate.glasses
import android.content.Context
import android.os.BatteryManager
import android.os.Build
import android.os.Environment
import android.os.StatFs
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import pw.binom.viewmate.core.MediaCommand
import pw.binom.viewmate.core.net.GlassesWsClient
import pw.binom.viewmate.core.protocol.AssistantStateMsg
import pw.binom.viewmate.core.protocol.ChatSessionMsg
import pw.binom.viewmate.core.protocol.DeleteFiles
import pw.binom.viewmate.core.protocol.DownloadFiles
import pw.binom.viewmate.core.protocol.GlassesStatus
import pw.binom.viewmate.core.protocol.GlassesToHost
import pw.binom.viewmate.core.protocol.Hello
import pw.binom.viewmate.core.protocol.HostToGlasses
import pw.binom.viewmate.core.protocol.MediaCommandMsg
import pw.binom.viewmate.core.protocol.PlayVideo
import pw.binom.viewmate.core.protocol.PlaybackPosition
import pw.binom.viewmate.core.protocol.SetMode
import pw.binom.viewmate.core.protocol.SetScale
import pw.binom.viewmate.core.protocol.ShowText
import pw.binom.viewmate.core.protocol.SttCancel
import pw.binom.viewmate.core.protocol.SttDone
import pw.binom.viewmate.core.protocol.SttPhrase
import pw.binom.viewmate.core.protocol.Welcome
/**
* Обвязка над [GlassesWsClient] для UI очков:
* mDNS-поиск телефона (с fallback'ом на эмуляторный адрес), реконнект,
* Hello при подключении, периодический статус/позиция,
* обработка входящих HostToGlasses (PlayVideo/MediaCommand выполняются плеером,
* DownloadFiles/DeleteFiles — менеджером скачивания).
*/
class HostConnection(
/** Адрес-фолбэк (эмулятор: ws://10.0.2.2:8080/ws/glasses); mDNS-поиск в приоритете. */
private val fallbackUrl: String,
private val movieController: MovieController,
private val downloadManager: GlassesDownloadManager,
context: Context,
) {
private val appContext = context.applicationContext
private val discovery = MdnsClient(context)
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
/** Активный WS-клиент (создаётся после выбора адреса mDNS/fallback). */
@Volatile
private var client: GlassesWsClient? = null
/** Последний найденный адрес — реконнект переиспользует его (без нового mDNS-цикла). */
@Volatile
private var lastUrl: String? = null
private val _connected = MutableStateFlow(false)
val connected: StateFlow<Boolean> = _connected.asStateFlow()
private val _status = MutableStateFlow("не подключено")
val status: StateFlow<String> = _status.asStateFlow()
private val _messages = MutableStateFlow<List<String>>(emptyList())
val messages: StateFlow<List<String>> = _messages.asStateFlow()
/** Флаг «плеер играл при обрыве» — продолжим по восстановлении связи. */
private val reconnectResume = ReconnectResumeFlag()
fun start() {
scope.launch { connectLoop() }
scope.launch {
while (isActive) {
delay(5_000)
client?.send(buildStatus())
val snapshot = withContext(Dispatchers.Main) { movieController.playbackPosition() }
if (snapshot.playing) movieController.markPosition(snapshot.positionMs)
client?.send(
PlaybackPosition(
positionMs = snapshot.positionMs,
playing = snapshot.playing,
itemId = snapshot.itemId,
audioIndex = snapshot.audioIndex,
),
)
}
}
}
/**
* Первое подключение: mDNS-дискавери телефона (до 15с на устройстве, 3с на
* эмуляторе), найденный адрес — в приоритете, иначе scan /24 (план Б).
* Fallback на эмуляторный 10.0.2.2 только на эмуляторе; на устройстве при
* пустом адресе — повторный поиск через 3с (lastUrl не запоминается).
* Внутри [GlassesWsClient.connect] живёт бесконечный реконнект с тем же
* адресом (переиспользование последнего найденного); при его отсутствии —
* повторный mDNS-цикл.
*/
private suspend fun connectLoop() {
val url = lastUrl ?: discoverPhoneUrl()
if (url.isEmpty()) {
log("app", "телефон не найден — повтор поиска через 3с")
delay(3_000)
connectLoop()
return
}
lastUrl = url
log("app", "подключаюсь к $url")
val c = GlassesWsClient(url)
client = c
c.connect(
onHostMessage = { msg -> handleHostMessage(msg) },
onConnected = {
_connected.value = true
_status.value = "подключено: $url"
addMessage("соединение установлено")
discovery.stop()
c.send(Hello(appVersion = APP_VERSION))
if (reconnectResume.consume()) {
withContext(Dispatchers.Main) { movieController.resume() }
addMessage("связь восстановлена — продолжаю")
}
},
onDisconnected = {
_connected.value = false
_status.value = "нет соединения (реконнект через 3с)"
val wasPlaying = withContext(Dispatchers.Main) { movieController.pauseIfPlaying() }
reconnectResume.onConnectionLost(wasPlaying)
if (wasPlaying) addMessage("связь потеряна — пауза")
},
)
}
/**
* Цепочка поиска адреса телефона: mDNS → scan /24 (план Б).
* На эмуляторе scan пропускается (телефона в эмуляторной /24 нет) и сразу
* наступает fallback на 10.0.2.2. На устройстве fallback запрещён — при
* неудачном поиске возвращается пустой адрес (повторный поиск в [connectLoop]).
*/
private suspend fun discoverPhoneUrl(): String {
val found = discovery.findPhoneIp(timeoutMs = MdnsClient.mdnsTimeoutMs(isEmulator()))
if (found != null) {
val url = MdnsClient.phoneWsUrl(found)
log("app", "mDNS нашёл телефон: $url")
return url
}
if (!isEmulator()) {
val localIp = MdnsClient.localIpv4()
if (localIp != null) {
val scanned = PhoneScan.findPhoneWsUrl(localIp)
if (scanned != null) return scanned
} else {
log("app", "нет локального IPv4 — scan /24 пропущен")
}
}
if (isEmulator()) {
log("app", "эмулятор — fallback на $fallbackUrl")
return fallbackUrl
}
log("app", "mDNS и scan не нашли телефон — повтор поиска через 3с")
return ""
}
/** Отправка сообщения на телефон (используется менеджером скачивания). */
suspend fun sendToHost(msg: GlassesToHost) {
client?.send(msg)
}
/** Немедленно отправить текущую позицию (пауза/возобновление экрана). */
fun sendPlaybackPositionNow() {
val c = client ?: return
scope.launch {
val (positionMs, playing, itemId, audioIndex) =
withContext(Dispatchers.Main) { movieController.playbackPosition() }
c.send(PlaybackPosition(positionMs, playing, itemId, audioIndex))
}
}
/**
* Эмулятор (AVD/Genymotion): mDNS отсутствует, долгий поиск бессмыслен —
* таймаут сокращается до [MdnsClient.EMULATOR_TIMEOUT_MS] и быстрее
* наступает fallback на 10.0.2.2. Детект по [Build.FINGERPRINT] (префикс
* `generic`/`sdk`) и свойству ro.kernel.qemu (скрытый API — через отражение).
*/
private fun isEmulator(): Boolean {
val fingerprint = Build.FINGERPRINT
if (fingerprint.startsWith("generic") || fingerprint.startsWith("sdk")) return true
return runCatching {
val method = Class.forName("android.os.SystemProperties")
.getMethod("get", String::class.java)
(method.invoke(null, "ro.kernel.qemu") as? String) == "1"
}.getOrDefault(false)
}
private fun buildStatus(): GlassesStatus {
val (totalBytes, freeBytes) = diskBytes()
val usedBytes = (totalBytes - freeBytes).coerceAtLeast(0L)
val media = downloadManager.mediaBytesNow()
return GlassesStatus(
batteryPercent = batteryPercent(),
storageUsedGb = usedBytes / 1_000_000_000.0,
storageTotalGb = totalBytes / 1_000_000_000.0,
storageTotalBytes = totalBytes,
storageFreeBytes = freeBytes,
mediaBytes = media,
downloadedItemIds = downloadManager.downloadedIds.value.toList(),
mediaDir = downloadManager.baseDir.absolutePath,
appVersion = APP_VERSION,
)
}
private fun batteryPercent(): Int {
val bm = appContext.getSystemService(Context.BATTERY_SERVICE) as? BatteryManager
val level = bm?.getIntProperty(BatteryManager.BATTERY_PROPERTY_CAPACITY)
return if (level != null && level in 0..100) level else 80
}
private fun diskBytes(): Pair<Long, Long> = runCatching {
val stat = StatFs(Environment.getDataDirectory().absolutePath)
stat.totalBytes to stat.availableBytes
}.getOrDefault(0L to 0L)
private suspend fun handleHostMessage(msg: HostToGlasses) {
when (msg) {
is Welcome -> {
_connected.value = true
addMessage("Welcome: режим ${msg.mode}, сессия ${msg.sessionId ?: "-"}")
}
is SetMode -> addMessage("SetMode: ${msg.mode}")
is SetScale -> {
GlassesApp.instance.applyScale(msg.scale)
log("app", "scale → ${msg.scale}")
}
is PlayVideo -> {
addMessage("получено видео: «${msg.title}» — ${msg.videoUrl}")
reconnectResume.reset()
movieController.playVideo(msg)
}
is MediaCommandMsg -> {
addMessage("MediaCommand: ${msg.command} (seekMs=${msg.seekMs})")
if (msg.command == MediaCommand.CLOSE) reconnectResume.reset()
movieController.applyMediaCommand(msg.command, msg.seekMs)
}
is DownloadFiles -> {
addMessage("скачивание на очки: «${msg.title}» (${msg.files.size} файлов)")
downloadManager.handleDownloadFiles(msg)
}
is DeleteFiles -> {
addMessage("удаление с очков: ${msg.itemId}")
downloadManager.handleDeleteFiles(msg.itemId)
}
is AssistantStateMsg -> addMessage("AssistantState: ${msg.state} (${msg.recordingSeconds}с)")
is ShowText -> {
addMessage("ShowText: ${msg.text}")
GlassesApp.instance.assistantText.value = msg.text
}
is ChatSessionMsg -> addMessage("ChatSession: ${msg.sessionId} — ${msg.title}")
is SttPhrase -> {
GlassesApp.instance.sttText.value = msg.full
log("stt", "фраза: ${msg.phrase}")
}
is SttDone -> {
log("stt", "ВЕСЬ ТЕКСТ: ${msg.full}")
GlassesApp.instance.listening.value = false
}
is SttCancel -> {
log("stt", "отменено")
GlassesApp.instance.listening.value = false
}
}
}
private fun addMessage(text: String) {
log("glasses", text)
_messages.value = (_messages.value + "» $text").takeLast(50)
}
fun stop() {
client?.close()
discovery.stop()
scope.cancel()
}
companion object {
private const val APP_VERSION = "0.1.0"
}
}