Ассистент Порфирий: тула времени, сессии, чат-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:
2026-08-22 16:31:04 +03:00
parent adfe376dd4
commit bbf7a020d4
28 changed files with 1802 additions and 342 deletions
@@ -0,0 +1,20 @@
package pw.binom.viewmate.core.net
/**
* Константы Bluetooth-транспорта (SPP/RFCOMM) — TASK-transport.md п.3/п.4.
* Общие для стороны очков (клиент) и телефона (сервер): UUID-сервиса SPP,
* имя сервиса, разделитель строк-сообщений, период реконнекта.
*/
object BtTransportConstants {
/** UUID SPP-сервиса (Serial Port Profile, стандартный Bluetooth SIG). */
const val SPP_UUID = "00001101-0000-1000-8000-00805F9B34FB"
/** Имя SPP-сервиса (RFCOMM-заголовок + человекочитаемый лог). */
const val SERVICE_NAME = "viewmate-spp"
/** Разделитель сообщений (JSON + перевод строки) в SPP. */
const val NEWLINE: Char = '\n'
/** Задержка (мс) между попытками реконнекta BT (оба конца — одинаковый цикл). */
const val RETRY_DELAY_MS: Long = 3_000L
}
@@ -0,0 +1,63 @@
package pw.binom.viewmate.core.net
/**
* Транспорт связи очки↔телефон, сторона очков («клиент») — TASK-transport.md п.1.
* Сообщения ходят **JSON-строками**: декод/кодинг конвертов [HostToGlasses]/
* [GlassesToHost] делает вызывающий (HostConnection на очках, GlassesHub на
* телефоне) — транспорт остаётся независимым от протокола.
*
* Реализации: [WifiGlassesTransport] (WebSocket, дефолт) и BtGlassesTransport
* (Bluetooth SPP, флагом).
*/
interface GlassesTransport {
/** Название транспорта: `wifi` / `bluetooth`. */
val name: String
/**
* Подключиться к телефону: бесконечный цикл реконнекта (ретрай через 3с при
* обрыве) живёт внутри реализации, в корутине вызывающего.
*
* @param onMessage входящее JSON-сообщение (конверт HostToGlasses, строкой).
*/
suspend fun connect(
onMessage: suspend (String) -> Unit,
onConnected: suspend () -> Unit,
onDisconnected: suspend () -> Unit,
)
/** Отправить JSON-сообщение (конверт GlassesToHost) на телефон. Без активного соединения — no-op. */
suspend fun send(json: String)
/** Закрыть транспорт: остановить цикл реконнекта и активное соединение. */
fun close()
}
/**
* Транспорт приёма соединений, сторона телефона («сервер») — TASK-transport.md п.1.
* [accept] выполняется бесконечно: принимает подключения очков и отдаёт события
* по строковым [connId] — не по объектам сокета/сессии (требование ТЗ:
* «сессии — connId-строки»). Сообщения — JSON-строки, декод — не в его
* заботе (запускающий: GlassesHub).
*/
interface GlassesServerTransport {
/** Название транспорта: `wifi` / `bluetooth`. */
val name: String
/**
* Бесконечно принимать подключения очков (в корутине вызывающего).
* @param onMessage входящее JSON-сообщение (конверт GlassesToHost) из соединения [connId].
* @param onConnected установлено новое соединение [connId].
* @param onDisconnected соединение [connId] оборвано.
*/
suspend fun accept(
onMessage: suspend (connId: String, json: String) -> Unit,
onConnected: (connId: String) -> Unit,
onDisconnected: (connId: String) -> Unit,
)
/** Отправить JSON-сообщение конкретному соединению [connId]. Соединения нет — no-op. */
suspend fun send(connId: String, json: String)
/** Закрыть транспорт: перестать принимать и закрыть активные соединения. */
fun close()
}
@@ -10,23 +10,21 @@ import io.ktor.websocket.send
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.cancel
import kotlinx.coroutines.delay
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.encodeToString
import pw.binom.viewmate.core.log
import pw.binom.viewmate.core.media.defaultWsClient
import pw.binom.viewmate.core.protocol.GlassesToHost
import pw.binom.viewmate.core.protocol.HostToGlasses
import pw.binom.viewmate.core.protocol.protocolJson
/**
* WS-клиент очков: подключается к хосту (телефону), шлёт [GlassesToHost],
* принимает [HostToGlasses]. [connect] — бесконечный цикл приёма с
* реконнектом через 3с при обрыве.
* WS-клиент очков: подключается к хосту (телефону). Работает **JSON-строками**
* (TASK-transport.md п.2 — протокол не в заботах транспорта): [connect]
* бесконечный цикл приёма с реконнектом через 3с при обрыве, [send] — строка
* (декод/кодинг вызывающий, т.е. HostConnection поверх [GlassesTransport]).
*/
class GlassesWsClient(
private val url: String, // ws://<host>:8080/ws/glasses
private val http: HttpClient = defaultWsClient(),
) {
) : GlassesTransport {
override val name = "wifi"
@Volatile
private var closed = false
@@ -36,13 +34,13 @@ class GlassesWsClient(
/**
* Подключиться и слушать. Возвращается при вызове [close].
* [onHostMessage] — входящие от хоста; [onConnected]/[onDisconnected] —
* [onMessage] — входящие JSON-строки от хоста; [onConnected]/[onDisconnected] —
* события (пере)подключения (для Hello и смены статуса в UI).
*/
suspend fun connect(
onHostMessage: suspend (HostToGlasses) -> Unit,
onConnected: suspend () -> Unit = {},
onDisconnected: suspend () -> Unit = {},
override suspend fun connect(
onMessage: suspend (String) -> Unit,
onConnected: suspend () -> Unit,
onDisconnected: suspend () -> Unit,
) {
while (!closed) {
try {
@@ -52,9 +50,7 @@ class GlassesWsClient(
try {
for (frame in ws.incoming) {
if (frame is Frame.Text) {
val text = frame.readText()
val msg = protocolJson.decodeFromString(HostToGlasses.serializer(), text)
onHostMessage(msg)
onMessage(frame.readText())
}
}
} finally {
@@ -72,14 +68,13 @@ class GlassesWsClient(
}
}
/** Отправить сообщение хосту. Без активного соединения — no-op. */
suspend fun send(msg: GlassesToHost) {
val text = protocolJson.encodeToString(GlassesToHost.serializer(), msg)
session?.send(text)
/** Отправить JSON-строку хосту. Без активного соединения — no-op. */
override suspend fun send(json: String) {
session?.send(json)
}
/** Закрыть клиент: выйти из цикла [connect] и закрыть активное соединение. */
fun close() {
override fun close() {
closed = true
session?.cancel()
}
@@ -0,0 +1,36 @@
package pw.binom.viewmate.core.net
import io.ktor.client.HttpClient
import pw.binom.viewmate.core.media.defaultWsClient
/**
* WiFi-транспорт, сторона очков («клиент») — TASK-transport.md п.2:
* [GlassesTransport] поверх [GlassesWsClient] — обёртка, логика цикла
* подключения/реконнекта не переписывается (переносится в интерфейс).
* Адрес (mDNS/scan/fallback — HostConnection) передаётся в URL.
*/
class WifiGlassesTransport(
url: String,
http: HttpClient = defaultWsClient(),
) : GlassesTransport {
override val name = "wifi"
private val client = GlassesWsClient(url, http)
override suspend fun connect(
onMessage: suspend (String) -> Unit,
onConnected: suspend () -> Unit,
onDisconnected: suspend () -> Unit,
) {
client.connect(onMessage, onConnected, onDisconnected)
}
override suspend fun send(json: String) {
client.send(json)
}
override fun close() {
client.close()
}
}
@@ -34,11 +34,20 @@ class GlassesWsClientTest {
companion object {
private const val PATH = "/ws/glasses"
private val json = protocolJson
private fun hostToGlasses(text: String): HostToGlasses =
json.decodeFromString(HostToGlasses.serializer(), text)
private fun glassesToHost(text: String): GlassesToHost =
json.decodeFromString(GlassesToHost.serializer(), text)
}
/** Локальный WS-сервер: Hello → Welcome, всё входящее собирает в список. */
/** Локальный WS-сервер: Hello → Welcome, всё входящее (JSON-строками) собирает в очередь. */
private class TestServer(private val port: Int = 0) {
val received = ConcurrentLinkedQueue<GlassesToHost>()
/** Входящие JSON-строки (транспорт — независим от протокола). */
val receivedRaw = ConcurrentLinkedQueue<String>()
private val sessions = ConcurrentLinkedQueue<DefaultWebSocketServerSession>()
private val server: EmbeddedServer<*, *> = embeddedServer(CIO, port = port, host = "127.0.0.1") {
@@ -54,10 +63,11 @@ class GlassesWsClientTest {
try {
for (frame in incoming) {
if (frame is Frame.Text) {
val msg = protocolJson.decodeFromString(GlassesToHost.serializer(), frame.readText())
received.add(msg)
val text = frame.readText()
receivedRaw.add(text)
val msg = glassesToHost(text)
if (msg is Hello) {
send(protocolJson.encodeToString(
send(json.encodeToString(
HostToGlasses.serializer(),
Welcome(mode = GlassesMode.MOVIE),
))
@@ -83,7 +93,7 @@ class GlassesWsClientTest {
get() = sessions.size
suspend fun broadcast(msg: HostToGlasses) {
val text = protocolJson.encodeToString(HostToGlasses.serializer(), msg)
val text = json.encodeToString(HostToGlasses.serializer(), msg)
for (s in sessions) {
try {
s.send(text)
@@ -114,20 +124,21 @@ class GlassesWsClientTest {
try {
runBlocking {
val client = GlassesWsClient(server.url())
val messages = mutableListOf<HostToGlasses>()
val messages = ConcurrentLinkedQueue<String>()
val connected = CompletableDeferred<Unit>()
val job = launch {
client.connect(
onHostMessage = { messages.add(it) },
onMessage = { messages.add(it) },
onConnected = { connected.complete(Unit) },
onDisconnected = {},
)
}
withTimeout(10_000) { connected.await() }
client.send(Hello(appVersion = "0.1.0"))
client.send(json.encodeToString(GlassesToHost.serializer(), Hello(appVersion = "0.1.0")))
awaitTrue { messages.any { it is Welcome } }
val welcome = messages.filterIsInstance<Welcome>().first()
awaitTrue { messages.any { hostToGlasses(it) is Welcome } }
val welcome = messages.first { hostToGlasses(it) is Welcome }.let { hostToGlasses(it) as Welcome }
assertEquals(GlassesMode.MOVIE, welcome.mode)
client.close()
@@ -147,16 +158,19 @@ class GlassesWsClientTest {
val connected = CompletableDeferred<Unit>()
val job = launch {
client.connect(
onHostMessage = {},
onMessage = {},
onConnected = { connected.complete(Unit) },
onDisconnected = {},
)
}
withTimeout(10_000) { connected.await() }
client.send(Gesture(gesture = "DoubleClick"))
client.send(json.encodeToString(GlassesToHost.serializer(), Gesture(gesture = "DoubleClick")))
awaitTrue { server.received.any { it is Gesture } }
val gesture = server.received.filterIsInstance<Gesture>().first()
awaitTrue { server.receivedRaw.any { glassesToHost(it) is Gesture } }
val gesture = server.receivedRaw.first { glassesToHost(it) is Gesture }.let {
glassesToHost(it) as Gesture
}
assertEquals("DoubleClick", gesture.gesture)
client.close()
@@ -173,12 +187,13 @@ class GlassesWsClientTest {
try {
runBlocking {
val client = GlassesWsClient(server.url())
val messages = mutableListOf<HostToGlasses>()
val messages = ConcurrentLinkedQueue<String>()
val connected = CompletableDeferred<Unit>()
val job = launch {
client.connect(
onHostMessage = { messages.add(it) },
onMessage = { messages.add(it) },
onConnected = { connected.complete(Unit) },
onDisconnected = {},
)
}
@@ -187,8 +202,8 @@ class GlassesWsClientTest {
server.broadcast(SetMode(GlassesMode.CHAT))
awaitTrue { messages.any { it is SetMode } }
val setMode = messages.filterIsInstance<SetMode>().first()
awaitTrue { messages.any { hostToGlasses(it) is SetMode } }
val setMode = messages.first { hostToGlasses(it) is SetMode }.let { hostToGlasses(it) as SetMode }
assertEquals(GlassesMode.CHAT, setMode.mode)
client.close()
@@ -199,6 +214,50 @@ class GlassesWsClientTest {
}
}
/**
* Проверка самого [GlassesTransport]: обмен — сырыми JSON-строками —
* и на стороне отправки (encode вызывающий), и на стороне приёма.
*/
@Test
fun transportApiExchangesRawJsonStrings() {
val server = TestServer()
try {
runBlocking {
val transport = WifiGlassesTransport(server.url())
val rawMessages = ConcurrentLinkedQueue<String>()
val connected = CompletableDeferred<Unit>()
val job = launch {
transport.connect(
onMessage = { rawMessages.add(it) },
onConnected = { connected.complete(Unit) },
onDisconnected = {},
)
}
withTimeout(10_000) { connected.await() }
assertEquals("wifi", transport.name)
transport.send(json.encodeToString(GlassesToHost.serializer(), Hello(appVersion = "0.3.0")))
awaitTrue { server.receivedRaw.any { it.contains("\"hello\"") } }
val helloJson = server.receivedRaw.first { it.contains("\"hello\"") }
val hello = glassesToHost(helloJson) as Hello
assertEquals("0.3.0", hello.appVersion)
// исходящее событие — тоже сырая строка; декод — за вызывающим
// (первый Welcome(MOVIE) — автоответ сервера на Hello; ждём наш CHAT)
server.broadcast(Welcome(mode = GlassesMode.CHAT))
awaitTrue { rawMessages.any { (hostToGlasses(it) as? Welcome)?.mode == GlassesMode.CHAT } }
val chatted = rawMessages.first { (hostToGlasses(it) as? Welcome)?.mode == GlassesMode.CHAT }
assertEquals(GlassesMode.CHAT, (hostToGlasses(chatted) as Welcome).mode)
transport.close()
job.cancelAndJoin()
}
} finally {
server.stop()
}
}
@Test
fun reconnectsAfterServerDrop() {
val server = TestServer()
@@ -209,8 +268,9 @@ class GlassesWsClientTest {
val client = GlassesWsClient(server.url())
val job = launch {
client.connect(
onHostMessage = {},
onMessage = {},
onConnected = { connects++ },
onDisconnected = {},
)
}