Ассистент Порфирий: тула времени, сессии, чат-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:
@@ -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 = {},
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user