a434d5bb12
- 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: строка про ассистента
309 lines
13 KiB
Kotlin
309 lines
13 KiB
Kotlin
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"
|
||
}
|
||
}
|