Ассистент Порфирий: тула времени, сессии, чат-UI, голосовой цикл; BT-транспорт очки↔телефон
- BT (RFCOMM SPP 00001105) вместо WiFi: BtGlassesTransport/BtServerTransport, реконнект 3с - PlaybackService (foreground, WAKE_LOCK) против морозов HONOR - Ассистент: AgentTools (TimeTool ISO 8601), TOOL_CALL-протокол, сессии SQLite (UUID) - Чат-UI: создание/сброс/переключение сессий, автообновление ленты 2с - LlmClient на прямом OkHttp (Koog давал пустой content на длинных историях) - Голосовой цикл: тройной тап на очках → STT → активная сессия → ShowText - Тесты: AgentToolsTest (3), GlassesServerTest переписан (6) — зелёные
This commit is contained in:
@@ -12,12 +12,15 @@ import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.serialization.decodeFromString
|
||||
import kotlinx.serialization.encodeToString
|
||||
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.net.GlassesTransport
|
||||
import pw.binom.viewmate.core.net.WifiGlassesTransport
|
||||
import pw.binom.viewmate.core.protocol.AssistantStateMsg
|
||||
import pw.binom.viewmate.core.protocol.ChatSessionMsg
|
||||
import pw.binom.viewmate.core.protocol.DeleteFiles
|
||||
@@ -37,13 +40,15 @@ 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
|
||||
import pw.binom.viewmate.core.protocol.protocolJson
|
||||
|
||||
/**
|
||||
* Обвязка над [GlassesWsClient] для UI очков:
|
||||
* mDNS-поиск телефона (с fallback'ом на эмуляторный адрес), реконнект,
|
||||
* Hello при подключении, периодический статус/позиция,
|
||||
* обработка входящих HostToGlasses (PlayVideo/MediaCommand выполняются плеером,
|
||||
* DownloadFiles/DeleteFiles — менеджером скачивания).
|
||||
* Обвязка над [GlassesTransport] для UI очков (TASK-transport.md п.1/п.4):
|
||||
* mDNS-поиск телефона (с fallback'ом на эмуляторный адрес, остаётся на
|
||||
* WiFi-стороне — [WifiGlassesTransport]), реконнект, Hello при подключении,
|
||||
* периодический статус/позиция, обработка входящих HostToGlasses (строкой из
|
||||
* транспорта; декод — здесь, не в транспорте). Дефолтный транспорт — WiFi;
|
||||
* Bluetooth SPP ([BtGlassesTransport]) — флагом [GlassesConfig.USE_BLUETOOTH].
|
||||
*/
|
||||
class HostConnection(
|
||||
/** Адрес-фолбэк (эмулятор: ws://10.0.2.2:8080/ws/glasses); mDNS-поиск в приоритете. */
|
||||
@@ -56,11 +61,14 @@ class HostConnection(
|
||||
private val discovery = MdnsClient(context)
|
||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||
|
||||
/** Активный WS-клиент (создаётся после выбора адреса mDNS/fallback). */
|
||||
@Volatile
|
||||
private var client: GlassesWsClient? = null
|
||||
/** Транспортом включается выбор [GlassesConfig.USE_BLUETOOTH]: дефолт — WiFi. */
|
||||
private val useBt: Boolean = GlassesConfig.USE_BLUETOOTH
|
||||
|
||||
/** Последний найденный адрес — реконнект переиспользует его (без нового mDNS-цикла). */
|
||||
/** Активный транспорт (создаётся после выбора адреса: mDNS/fallback или SPP-MAC). */
|
||||
@Volatile
|
||||
private var transport: GlassesTransport? = null
|
||||
|
||||
/** Последний найденный адрес — реконнект переиспользует его (WiFi: URL, BT: MAC). */
|
||||
@Volatile
|
||||
private var lastUrl: String? = null
|
||||
|
||||
@@ -81,10 +89,10 @@ class HostConnection(
|
||||
scope.launch {
|
||||
while (isActive) {
|
||||
delay(5_000)
|
||||
client?.send(buildStatus())
|
||||
sendToHost(buildStatus())
|
||||
val snapshot = withContext(Dispatchers.Main) { movieController.playbackPosition() }
|
||||
if (snapshot.playing) movieController.markPosition(snapshot.positionMs)
|
||||
client?.send(
|
||||
sendToHost(
|
||||
PlaybackPosition(
|
||||
positionMs = snapshot.positionMs,
|
||||
playing = snapshot.playing,
|
||||
@@ -96,36 +104,80 @@ class HostConnection(
|
||||
}
|
||||
}
|
||||
|
||||
/** Развилка по [useBt]: WiFi (mDNS/scan/fallback) или SPP (адрес — [GlassesConfig.btRemoteMac]). */
|
||||
private suspend fun connectLoop() {
|
||||
if (useBt) connectLoopBt() else connectLoopWifi()
|
||||
}
|
||||
|
||||
/**
|
||||
* Первое подключение: mDNS-дискавери телефона (до 15с на устройстве, 3с на
|
||||
* эмуляторе), найденный адрес — в приоритете, иначе scan /24 (план Б).
|
||||
* Первое подключение (WiFi): mDNS-дискавери телефона (до 15с на устройстве,
|
||||
* 3с на эмуляторе), найденный адрес — в приоритете, иначе scan /24 (план Б).
|
||||
* Fallback на эмуляторный 10.0.2.2 только на эмуляторе; на устройстве при
|
||||
* пустом адресе — повторный поиск через 3с (lastUrl не запоминается).
|
||||
* Внутри [GlassesWsClient.connect] живёт бесконечный реконнект с тем же
|
||||
* Внутри [GlassesTransport.connect] живёт бесконечный реконнект с тем же
|
||||
* адресом (переиспользование последнего найденного); при его отсутствии —
|
||||
* повторный mDNS-цикл.
|
||||
*/
|
||||
private suspend fun connectLoop() {
|
||||
private suspend fun connectLoopWifi() {
|
||||
val url = lastUrl ?: discoverPhoneUrl()
|
||||
if (url.isEmpty()) {
|
||||
log("app", "телефон не найден — повтор поиска через 3с")
|
||||
delay(3_000)
|
||||
connectLoop()
|
||||
connectLoopWifi()
|
||||
return
|
||||
}
|
||||
lastUrl = url
|
||||
log("app", "подключаюсь к $url")
|
||||
log("app", "подключаюсь к $url (WiFi)")
|
||||
|
||||
val c = GlassesWsClient(url)
|
||||
client = c
|
||||
c.connect(
|
||||
onHostMessage = { msg -> handleHostMessage(msg) },
|
||||
val t = WifiGlassesTransport(url)
|
||||
transport = t
|
||||
t.connect(
|
||||
onMessage = { text -> handleHostMessageText(text) },
|
||||
onConnected = {
|
||||
_connected.value = true
|
||||
_status.value = "подключено: $url"
|
||||
_status.value = "подключено: ${t.name} $url"
|
||||
addMessage("соединение установлено")
|
||||
discovery.stop()
|
||||
c.send(Hello(appVersion = APP_VERSION))
|
||||
sendToHost(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("связь потеряна — пауза")
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* SPP-цикл (BT): адрес телефона — [GlassesConfig.btRemoteMac] (введено
|
||||
* вручную), реконнект-цикл живёт внутри [BtGlassesTransport.connect].
|
||||
*/
|
||||
private suspend fun connectLoopBt() {
|
||||
val mac = lastUrl ?: (GlassesConfig.btRemoteMac() ?: "")
|
||||
if (mac.isEmpty()) {
|
||||
log("app", "BT: MAC-адрес телефона не задан — SPP недоступен")
|
||||
delay(3_000)
|
||||
connectLoopBt()
|
||||
return
|
||||
}
|
||||
lastUrl = mac
|
||||
log("app", "подключаюсь к $mac (Bluetooth SPP)")
|
||||
|
||||
val t = BtGlassesTransport(appContext)
|
||||
transport = t
|
||||
t.connect(
|
||||
onMessage = { text -> handleHostMessageText(text) },
|
||||
onConnected = {
|
||||
_connected.value = true
|
||||
_status.value = "подключено: bluetooth $mac"
|
||||
addMessage("соединение установлено")
|
||||
sendToHost(Hello(appVersion = APP_VERSION))
|
||||
if (reconnectResume.consume()) {
|
||||
withContext(Dispatchers.Main) { movieController.resume() }
|
||||
addMessage("связь восстановлена — продолжаю")
|
||||
@@ -145,7 +197,7 @@ class HostConnection(
|
||||
* Цепочка поиска адреса телефона: mDNS → scan /24 (план Б).
|
||||
* На эмуляторе scan пропускается (телефона в эмуляторной /24 нет) и сразу
|
||||
* наступает fallback на 10.0.2.2. На устройстве fallback запрещён — при
|
||||
* неудачном поиске возвращается пустой адрес (повторный поиск в [connectLoop]).
|
||||
* неудачном поиске возвращается пустой адрес (повторный поиск в [connectLoopWifi]).
|
||||
*/
|
||||
private suspend fun discoverPhoneUrl(): String {
|
||||
val found = discovery.findPhoneIp(timeoutMs = MdnsClient.mdnsTimeoutMs(isEmulator()))
|
||||
@@ -171,18 +223,29 @@ class HostConnection(
|
||||
return ""
|
||||
}
|
||||
|
||||
/** Входящая JSON-строка из транспорта: декод конверта [HostToGlasses] здесь (не в транспорте). */
|
||||
private suspend fun handleHostMessageText(text: String) {
|
||||
val msg = runCatching {
|
||||
protocolJson.decodeFromString(HostToGlasses.serializer(), text)
|
||||
}.onFailure {
|
||||
log("glasses", "ОШИБКА декодирования входящего: ${it::class.simpleName}: ${it.message} (текст: ${text.take(120)})")
|
||||
}.getOrNull() ?: return
|
||||
handleHostMessage(msg)
|
||||
}
|
||||
|
||||
/** Отправка сообщения на телефон (используется менеджером скачивания). */
|
||||
suspend fun sendToHost(msg: GlassesToHost) {
|
||||
client?.send(msg)
|
||||
val t = transport ?: return
|
||||
t.send(protocolJson.encodeToString(GlassesToHost.serializer(), msg))
|
||||
}
|
||||
|
||||
/** Немедленно отправить текущую позицию (пауза/возобновление экрана). */
|
||||
fun sendPlaybackPositionNow() {
|
||||
val c = client ?: return
|
||||
if (transport == null) return
|
||||
scope.launch {
|
||||
val (positionMs, playing, itemId, audioIndex) =
|
||||
withContext(Dispatchers.Main) { movieController.playbackPosition() }
|
||||
c.send(PlaybackPosition(positionMs, playing, itemId, audioIndex))
|
||||
sendToHost(PlaybackPosition(positionMs, playing, itemId, audioIndex))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -306,7 +369,7 @@ class HostConnection(
|
||||
}
|
||||
|
||||
fun stop() {
|
||||
client?.close()
|
||||
transport?.close()
|
||||
discovery.stop()
|
||||
scope.cancel()
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user