diff --git a/.cortexkit/.gitignore b/.cortexkit/.gitignore new file mode 100644 index 0000000..ee3fa48 --- /dev/null +++ b/.cortexkit/.gitignore @@ -0,0 +1,3 @@ +# >>> cortexkit:magic-context +magic-context/ +# <<< cortexkit:magic-context diff --git a/TASK-transport.md b/TASK-transport.md new file mode 100644 index 0000000..ba7f801 --- /dev/null +++ b/TASK-transport.md @@ -0,0 +1,103 @@ +# Абстракция транспорта связи очки↔телефон (view-mate) + +## Проблема + +Сейчас связь очки↔телефон жёстко завязана на **WiFi + WebSocket**: +- очки: `GlassesWsClient` (Ktor WS-клиент) внутри `HostConnection`, discovery = mDNS (`MdnsClient`) + скан /24 (`PhoneScan`) +- телефон: `GlassesServer` (Ktor CIO embeddedServer, WS на 0.0.0.0:8080/ws/glasses) + `GlassesHub` (сессии = `DefaultWebSocketServerSession`) + +WiFi на реальном железе нестабилен (пользователь: «wifi в рамках view-mate — неверное решение, блютус на много стабильнее»). Нужно сделать транспорт **абстрактным**, чтобы реализацию можно было подставить: **WifiTransport** (текущий WS) и **BluetoothTransport** (новый, RFCOMM/BluetoothSocket). + +## Цель + +Выделить интерфейс транспорта, одинаковый для обеих сторон, и две реализации: +1. **WifiTransport** — обёртка над существующим кодом (без изменения логики) +2. **BluetoothTransport** — новая реализация на Android BluetoothSocket (RFCOMM) + +Протокол сообщений (HostToGlasses/GlassesToHost, kotlinx.serialization JSON) — **НЕ трогать** (пересылаются как JSON-строки, что WS, что BT-сокет). + +## Текущая структура (не менять без нужды, только рефакторинг) + +**lib-core/src/commonMain/kotlin/pw/binom/viewmate/core/** +- `net/GlassesWsClient.kt` — WS-клиент очков: `suspend connect(onHostMessage, onConnected, onDisconnected)` (бесконечный цикл приёма с реконнектом 3с), `suspend send(msg: GlassesToHost)`, `close()` +- `phone/GlassesSender.kt` — интерфейс телефона: `interface GlassesSender { suspend fun send(msg: HostToGlasses) }` +- `protocol/` — HostToGlasses, GlassesToHost, Json (protocolJson), MediaCommand и т.д. + +**app-glasses/src/main/kotlin/pw/binom/viewmate/glasses/** +- `HostConnection.kt` — обвязка: discovery (MdnsClient → PhoneScan), connectLoop, реконнект, Hello, статус-цикл 5с (buildStatus + PlaybackPosition), обработка HostToGlasses (handleHostMessage), reconnectResume-автоматика +- `MdnsClient.kt`, `PhoneScan.kt` — WiFi-discovery + +**app-phone/src/main/kotlin/pw/binom/viewmate/phone/** +- `GlassesServer.kt` — `GlassesHub` (ConcurrentHashMap, connected/gesturesReceived/batteryPercent, broadcast, обработка входящих: Hello→Welcome, жесты, позиция, GlassesOff→пауза) + `GlassesServer` (embeddedServer CIO + glassesServerModule + sender-адаптер GlassesSender поверх hub.broadcast) +- `NsdPublisher.kt` — mDNS-публикация + +## Задача + +### 1. Интерфейсы транспорта (lib-core, commonMain) + +Создать `core/net/GlassesTransport.kt` (или аналогичный файл): + +```kotlin +/** Транспорт связи очки↔телефон. Реализации: WiFi (WS), Bluetooth (RFCOMM). */ +interface GlassesTransport { + val name: String // "wifi" / "bluetooth" + suspend fun connect( + onMessage: suspend (String) -> Unit, // входящее JSON-сообщение + onConnected: suspend () -> Unit, + onDisconnected: suspend () -> Unit, + ) + suspend fun send(json: String) + fun close() +} +``` + +Сообщения передавать **JSON-строками** (декодирование остаётся на стороне вызывающего — в HostConnection и GlassesHub, как сейчас). Это держит транспорт полностью независимым от протокола. + +Для телефона — интерфейс приёма подключений: +```kotlin +/** Приёмник подключений (телефон). */ +interface GlassesServerTransport { + val name: String + suspend fun accept( + onMessage: suspend (connId: String, json: String) -> Unit, + onConnected: suspend (connId: String) -> Unit, + onDisconnected: suspend (connId: String) -> Unit, + ) + suspend fun send(connId: String, json: String) + fun close() +} +``` +(connId — идентификатор сессии; для WiFi можно использовать адрес/хэш, для BT — MAC-адрес устройства.) + +### 2. WifiTransport (рефакторинг существующего) + +- **Очки**: `WifiGlassesTransport` — обёртка над `GlassesWsClient`: connect пробрасывает колбэки, но строки (не декодированные объекты). Либо изменить `GlassesWsClient` на работу со строками — на твоё усмотрение, главное чтобы HostConnection перешёл на интерфейс. +- **Телефон**: `WifiServerTransport` — обёртка над Ktor-сервером: существующий `glassesServerModule`/`GlassesHub` переводится на интерфейс (сессии — connId-строки вместо DefaultWebSocketServerSession). + +### 3. BluetoothTransport (новая реализация) + +**Очки (клиент):** +- `BtGlassesTransport` — подключается к телефону через BluetoothSocket (RFCOMM, SPP): `BluetoothAdapter.getDefaultAdapter()`, найти сервис/устройство (bonded devices + поиск), `createRfcommSocketToServiceRecord(SPP_UUID)`, connect, потоки: читать строки (по разделителю/длине), писать строки. +- Discovery: bonded-устройства телефона (первый подходящий) или поиск (startDiscovery) — достаточно начать с bonded. +- Пермишены: BLUETOOTH, BLUETOOTH_ADMIN (до Android 12), BLUETOOTH_CONNECT/BLUETOOTH_SCAN (Android 12+, runtime-запрос). + +**Телефон (сервер):** +- `BtServerTransport` — BluetoothServerSocket (RFCOMM, SPP, тот же UUID), accept-цикл, потоки чтения/записи на каждое подключение, connId = MAC устройства. +- Пермишены аналогично + FOREGROUND_SERVICE_CONNECTED_DEVICE (если нужно). + +### 4. Соединение (фабрика/выбор) + +- В `HostConnection` и `GlassesServer` — параметр транспорта (интерфейс), по умолчанию WifiTransport (чтобы не сломать текущее поведение). Выбор: константа/конфиг/аргумент конструктора. **По умолчанию — WiFi**, Bluetooth включается флагом (пользователь будет переключать вручную после проверки). +- Discovery (MdnsClient/PhoneScan/NsdPublisher) — оставить как часть WifiTransport; для BT — свой (bonded). + +### 5. Сборка и тесты + +- Существующие тесты (GlassesServerTest, HostToGlassesTest, GlassesToHostTest, PhoneActionsTest и др.) должны остаться зелёными — поведение WiFi не меняется. +- Добавить юнит-тесты на чистую логику (если есть что тестировать без Android: например, сериализация строк, выбор транспорта). + +## Ограничения + +- **НЕ менять протокол** (HostToGlasses/GlassesToHost/Json) — только способ доставки. +- НЕ ломать существующий WiFi-путь: он остаётся дефолтным и должен работать как раньше. +- Kotlin, стиль проекта (детали в соседних файлах). +- После рефакторинга — собрать `:app-phone:assembleDebug` и `:app-glasses:assembleDebug`, прогнать тесты, показать результат. diff --git a/TASK.md b/TASK.md index 51f2a90..1225e0a 100644 --- a/TASK.md +++ b/TASK.md @@ -1,106 +1,132 @@ -# Задача: масштаб на всех экранах очков + сохранение скролла каталога +# Задача: телефон не должен засыпать во время просмотра (foreground service) -## Баг 1: ConnectionScreen игнорирует масштаб +## Симптом (пользователь, 21.08) -**Симптом (пользователь):** «в режиме НЕ просмотра видео (пока ничего не включено) -очки игнорируют выставленный масштаб». Плюс «масштаб будто бы не сохраняется — -сразу после старта масштаб 1». +«Если я телефон отправлю спать, то звук на телефоне прекращается. А когда на +телефоне прекращается звук, очки тоже перестают играть.» -**Корень:** масштаб (`GlassesApp.instance.scale`, 0.5..1) применяется ТОЛЬКО в -`GlassesMovieScreen.kt` (контейнер `width((screenWidthPx * scale).toInt().px)` + -offset к центру). А `ConnectionScreen()` в -`app-glasses/src/main/kotlin/pw/binom/viewmate/glasses/MainActivity.kt` (строка ~354) -рисует контент на ПОЛНУЮ половину глаза (`Modifier.width(screenWidthPx.px)`) — -scale не читает вообще. Поэтому и «не сохраняется»: сохранённый scale в prefs есть, -но на не-видео экране он не виден. +## Корень (диагноз подтверждён) -**Фикс:** в `ConnectionScreen()` применять scale так же, как в GlassesMovieScreen: +Архитектура: звук играет ТОЛЬКО на телефоне (у очков нет динамиков), видео — +на очках, синк по позиции (очки — мастер, 5с-цикл). На телефоне НЕТ +foreground service и НЕТ wake/wifi lock — приложение живёт только в Activity +(`app-phone/src/main/AndroidManifest.xml`: только MainActivity, пермишенов +WAKE_LOCK нет). При выключении экрана HONOR (Android 16, агрессивная фоновая +политика) замораживает/убивает процесс: + +1. WS-сервер (`GlassesServer`, :8080) умирает → очки теряют связь (ping 5с) → + автоматика обрыва на очках («связь потеряна — пауза») останавливает ВИДЕО. +2. `AudioSyncPlayer` (ExoPlayer) заморожен → ЗВУК встаёт. + +## Решение + +Добавить в app-phone **foreground service** типа `mediaPlayback` + WAKE_LOCK + +WifiLock, чтобы процесс (WS-сервер + аудио-плеер) жил при выключенном экране +телефона. Очки тогда не теряют связь, видео не паузится, звук играет. + +### 1. Манифест (`app-phone/src/main/AndroidManifest.xml`) + +```xml + + + + +``` +Внутри `` — сервис: +```xml + +``` + +### 2. Сервис `app-phone/src/main/kotlin/pw/binom/viewmate/phone/PlaybackService.kt` ```kotlin -fun ConnectionScreen() { - val connection = GlassesApp.instance.hostConnection - val connected by connection.connected.collectAsState() - val status by connection.status.collectAsState() - val messages by connection.messages.collectAsState() - val scale by GlassesApp.instance.scale.collectAsState() // ← НОВОЕ - - Binocular(scale = false) { - val screenWidthPx = BinocularScreenSize.current.width - val screenHeightPx = BinocularScreenSize.current.height - // масштаб через layout — как в GlassesMovieScreen - val containerW = (screenWidthPx * scale).toInt() - val containerH = (screenHeightPx * scale).toInt() - val offsetX = (screenWidthPx * (1f - scale)).toInt() // сдвиг к носу (левый глаз) - Box( - modifier = Modifier - .width(containerW.px) - .height(containerH.px) - .offset(x = offsetX.px) - .background(Color.Black), - ) { - ... существующий контент ... +class PlaybackService : Service() { + companion object { + const val CHANNEL_ID = "viewmate_playback" + const val NOTIFICATION_ID = 1 + // аккуратный запуск: ContextCompat.startForegroundService(context, intent) + fun start(context: Context) { + ContextCompat.startForegroundService( + context, Intent(context, PlaybackService::class.java) + ) + } + fun stop(context: Context) { + context.stopService(Intent(context, PlaybackService::class.java)) } } + + private var wakeLock: PowerManager.WakeLock? = null + private var wifiLock: WifiManager.WifiLock? = null + + override fun onCreate() { + super.onCreate() + // канал уведомлений (Android 8+) + val channel = NotificationChannel( + CHANNEL_ID, "ViewMate playback", + NotificationManager.IMPORTANCE_LOW, + ).apply { setShowBadge(false) } + getSystemService(NotificationManager::class.java).createNotificationChannel(channel) + + val notification = NotificationCompat.Builder(this, CHANNEL_ID) + .setSmallIcon(android.R.drawable.ic_media_play) + .setContentTitle("ViewMate") + .setContentText("Звук на телефоне при просмотре на очках") + .setOngoing(true) + .build() + startForeground(NOTIFICATION_ID, notification) + + // держим CPU и WiFi живыми при выключенном экране + wakeLock = (getSystemService(POWER_SERVICE) as PowerManager) + .newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "viewmate:audio") + ?.apply { acquire() } + wifiLock = (getApplicationContext().getSystemService(WIFI_SERVICE) as WifiManager) + .createWifiLock(WifiManager.WIFI_MODE_FULL_HIGH_PERF, "viewmate:wifi") + ?.apply { acquire() } + } + + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int = + START_NOT_STICKY + + override fun onDestroy() { + wakeLock?.takeIf { it.isHeld }?.release() + wifiLock?.takeIf { it.isHeld }?.release() + super.onDestroy() + } + + override fun onBind(intent: Intent?): IBinder? = null } ``` +Импорты: androidx.core.app.NotificationCompat, androidx.core.content.ContextCompat. +Check: в gradle уже есть androidx.core (проверить libs.versions.toml — ktor/compose +тянут core-ktx, но для чистоты добавить `androidx.core:core-ktx` если нет). -Внутри контейнера всё остаётся: Column с текстами, StatusPanelCompose, -оверлей распознавания — но всё это теперь в уменьшенном контейнере. -Импорт `androidx.compose.foundation.layout.offset` и `width/height` уже есть. +### 3. Запуск/остановка сервиса в `PhoneApp.kt` -⚠️ ВАЖНО: `Modifier.offset(x=...)` — смещение внутри родителя. Внутри -`Binocular(scale=false)` контент рисуется на половину 640. При scale<1 контейнер -уже (например 448 при 0.7) и сдвинут к центру (к носу): offsetX = 640*(1-0.7)=192. -Для левого глаза сдвиг вправо (к центру 640), правый глаз — как в GlassesMovieScreen -(offset 0, контейнер прижат к центру слева). Смотри `scaleOffsetX` в -GlassesMovieScreen.kt:143 — скопировать ту же логику (side из BinocularContent, -там есть `Side.LEFT/RIGHT` через CompositionLocal). +- **Старт — при каждом старте аудио** (3 места): + - `autostartAudio()` (строка ~173, перед `audioSync.play(source, positionMs)`) + - `DetailsScreen.onWatch` — там где вызывается `app.audioSync.play(source, positionMs)` + (строка ~127) и второй play (строка ~215) + - ОБЩИЙ подход: раз звук — сервис. Проще всего: стартовать в + `PhoneApp.onCreate()` сразу (сервис лёгкий, уведомление висит пока + приложение живо) — НО только если пользователь не против постоянного + уведомления. РЕШЕНИЕ: старт в onCreate, стоп в onTerminate — сервис + живёт пока жив телефон-компаньон (WS-сервер тоже живёт всё время), + уведомление — постоянный индикатор «ViewMate работает». Это проще и + покрывает и watch, и autostartAudio, и STT-сессии. +- **Стоп**: в `onTerminate()` — `PlaybackService.stop(this)`. -## Баг 2: скролл каталога теряется при навигации +Решение принято: **старт в onCreate, стоп в onTerminate** (сервис = спутник +WS-сервера, а не аудио-сессии; уведомление постоянно, но это норма для +приложения-компаньона — так работает, например, в паре с очками). -**Симптом (пользователь):** «кручу вниз до нужной папки, захожу, тыкаю назад -(кнопку или аппаратную) и я оказываюсь в предыдущем окне в самом начале (скролл -не сохранился). Надо сохранять скролл, даже если зашёл в папку, потом ещё в -папку, потом ещё — и т.д.» +### 4. Проверка -**Корень:** `app-phone/src/main/kotlin/pw/binom/viewmate/phone/ui/CatalogScreen.kt`: -`LazyVerticalGrid` (строка 220) БЕЗ `LazyGridState` — позиция не хранится. -`CatalogState` живёт на корне (переживает переходы), но позицию скролла не помнит. - -**Фикс:** добавить в `CatalogState`: -- `private var scrollIndex by mutableStateOf(0)` / `scrollOffset by mutableStateOf(0)` — текущая позиция -- `private val savedScroll = mutableMapOf>()` — сохранённые позиции по пути -- в `openSeries`/`openSeason`/`openFolder`: **сначала** `savedScroll[path] = scrollIndex to scrollOffset`, **потом** менять path -- в `back()`: после смены path — восстановить `(scrollIndex, scrollOffset) = savedScroll[path] ?: (0,0)` -- методы `setScroll(index, offset)` (для snapshotFlow из UI) и `getSavedScroll(): Pair` (текущий path) - -В `CatalogScreen` composable: -```kotlin -val gridState = rememberLazyGridState() -// восстанавливаем позицию при смене пути/загрузке -LaunchedEffect(state.path, state.items) { - val (idx, off) = state.savedScrollForCurrentPath() - gridState.scrollToItem(idx, off) -} -// сохраняем позицию при скролле -LaunchedEffect(gridState) { - snapshotFlow { - gridState.firstVisibleItemIndex to gridState.firstVisibleItemScrollOffset - }.collect { (idx, off) -> state.setScroll(idx, off) } -} -``` -и `LazyVerticalGrid(state = gridState, ...)`. - -Ключ сохранения — РОДИТЕЛЬСКИЙ path (тот, откуда ушли). При back() path меняется -на родителя — достаём сохранённое. Вложенные папки работают автоматически: -каждый уровень сохраняет свою позицию. - -Импорты: `androidx.compose.foundation.lazy.grid.rememberLazyGridState`, -`androidx.compose.runtime.snapshotFlow`. - -## Критерии приёмки - -1. Сборка: `./gradlew :app-phone:assembleDebug :app-glasses:assembleDebug` — успех. -2. Юнит-тесты: `./gradlew :lib-core:testDebugUnitTest :app-phone:testDebugUnitTest :app-glasses:testDebugUnitTest` — зелёные. -3. Ручная проверка на очках: выставить масштаб 0.7 → ConnectionScreen (без видео) тоже уменьшен (чёрные поля по краям, картинка/текст сдвинуты к носу). -4. Ручная проверка: скролл каталога вниз → зайти в папку → назад → позиция на месте; то же для вложенных папок. +1. Сборка: `./gradlew :app-phone:assembleDebug` — успех. +2. Юнит-тесты: `./gradlew :app-phone:testDebugUnitTest` — зелёные. +3. На железе (через 142): установить APK, запустить, начать просмотр, + `adb -s shell input keyevent 26` (экран телефона в сон) → + проверить: звук играет, видео на очках играет (позиция растёт в логах), + сервис жив: `adb shell dumpsys activity services | grep -A3 PlaybackService`. diff --git a/app-glasses/src/main/AndroidManifest.xml b/app-glasses/src/main/AndroidManifest.xml index 831e31a..71da596 100644 --- a/app-glasses/src/main/AndroidManifest.xml +++ b/app-glasses/src/main/AndroidManifest.xml @@ -9,6 +9,13 @@ + + + + + = + if (Build.VERSION.SDK_INT >= 31) { + arrayOf(Manifest.permission.BLUETOOTH_CONNECT, Manifest.permission.BLUETOOTH_SCAN) + } else { + arrayOf(Manifest.permission.BLUETOOTH, Manifest.permission.BLUETOOTH_ADMIN) + } + + @SuppressLint("MissingPermission") + private fun hasBtPermissions(ctx: Context): Boolean = + runtimePermissions().all { + ContextCompat.checkSelfPermission(ctx, it) == PackageManager.PERMISSION_GRANTED + } + } + + override val name = "bluetooth" + + @Volatile + private var closed = false + + @Volatile + private var socket: BluetoothSocket? = null + + private val outLock = Any() + private var out: OutputStream? = null + + /** Бесконечный цикл подключения+реконнекта: в корутине вызывающего, до [close]. */ + @SuppressLint("MissingPermission") + override suspend fun connect( + onMessage: suspend (String) -> Unit, + onConnected: suspend () -> Unit, + onDisconnected: suspend () -> Unit, + ) { + while (!closed) { + var connected = false + if (socket == null) { + if (!ensureSocket()) { + log("bt", "SPP не создан (радио/пермишен/адрес?) — ретрай через ${BtTransportConstants.RETRY_DELAY_MS / 1000}с") + delay(BtTransportConstants.RETRY_DELAY_MS) + continue + } + log("bt", "SPP-клиент готов: ${GlassesConfig.btRemoteMac()}") + } + try { + val sock = socket!! + withContext(Dispatchers.IO) { sock.connect() } + connected = true + out = sock.outputStream + log("bt", "SPP-канал открыт: ${GlassesConfig.btRemoteMac()}") + onConnected() + // readLines: EOF (партнёр закрыл) или исключение (обрыв/close) + readLines(sock, onMessage) + if (!closed) { + log("bt", "SPP-соединение оборвано") + onDisconnected() + } + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + log("bt", "SPP-ошибка (${e::class.simpleName}: ${e.message}) — ретрай через ${BtTransportConstants.RETRY_DELAY_MS / 1000}с") + if (!closed && connected) runCatching { onDisconnected() } + } + runCatching { out?.close() } + out = null + runCatching { socket?.close() } + socket = null + if (closed) break + delay(BtTransportConstants.RETRY_DELAY_MS) + } + } + + /** RFCOMM/SPP-сокет к [GlassesConfig.btRemoteMac]; без радио/пермишена/адреса — false. */ + @SuppressLint("MissingPermission") + private fun ensureSocket(): Boolean { + val mac = GlassesConfig.btRemoteMac() + if (mac.isNullOrBlank()) { + log("bt", "адрес телефона не задан (GlassesConfig.BT_REMOTE_MAC) — SPP недоступен") + return false + } + val adapter = BluetoothAdapter.getDefaultAdapter() ?: run { + log("bt", "BT-радио не найдено — SPP недоступен") + return false + } + if (!adapter.isEnabled) { + log("bt", "Bluetooth выключен — включите в настройках Android") + return false + } + if (!hasBtPermissions(context)) { + log("bt", "нет runtime-пермишена Bluetooth: ${runtimePermissions().joinToString(", ")}") + return false + } + val dev = try { + adapter.getRemoteDevice(mac) + } catch (e: Exception) { + log("bt", "устройство не найдено: ${e.message}") + null + } + if (dev == null) return false + val s = try { + dev.createRfcommSocketToServiceRecord(UUID.fromString(BtTransportConstants.SPP_UUID)) + } catch (e: Exception) { + log("bt", "не удалось создать SPP-сокет: ${e.message}") + null + } + if (s == null) return false + socket = s + return true + } + + /** Read-поток (Dispatchers.IO): строки по [BtTransportConstants.NEWLINE] → [onMessage]. */ + private suspend fun readLines(s: BluetoothSocket, onMessage: suspend (String) -> Unit) { + val reader = BufferedReader(InputStreamReader(s.inputStream)) + while (!closed) { + val line = withContext(Dispatchers.IO) { reader.readLine() } ?: break + if (line.isNotBlank()) onMessage(line) + } + runCatching { reader.close() } + } + + /** JSON-строка в сокет: UTF-8 + разделитель, под синхронизацией. Без соединения — no-op. */ + @SuppressLint("MissingPermission") + override suspend fun send(json: String) { + val o = out ?: return + runCatching { + synchronized(outLock) { + o.write(json.toByteArray(Charsets.UTF_8)) + o.write(BtTransportConstants.NEWLINE.code) + o.flush() + } + }.onFailure { log("bt", "запись не удалась: ${it.message}") } + } + + override fun close() { + closed = true + try { + socket?.close() + } catch (_: Exception) { + } + socket = null + } +} diff --git a/app-glasses/src/main/kotlin/pw/binom/viewmate/glasses/GlassesApp.kt b/app-glasses/src/main/kotlin/pw/binom/viewmate/glasses/GlassesApp.kt index 9f871b8..2644164 100644 --- a/app-glasses/src/main/kotlin/pw/binom/viewmate/glasses/GlassesApp.kt +++ b/app-glasses/src/main/kotlin/pw/binom/viewmate/glasses/GlassesApp.kt @@ -11,6 +11,19 @@ object GlassesConfig { // Fallback: эмулятор — 10.0.2.2 (localhost хоста); на устройстве телефон // находится авто-обнаружением по mDNS (_viewmate._tcp). const val HOST_URL = "ws://10.0.2.2:8080/ws/glasses" + + /** + * Выбор транспорта (TASK-transport.md п.4): `false` (дефолт) — WiFi + * ([WifiGlassesTransport], поведение без изменений); `true` — Bluetooth SPP + * ([BtGlassesTransport], manual-включение). + */ + const val USE_BLUETOOTH = true + + /** MAC-адрес телефона-партнёра для SPP (нужен только при USE_BLUETOOTH=true). */ + val BT_REMOTE_MAC: String? = "08:84:FB:D5:BD:3E" + + /** Адрес SPP-партнёра (null → SPP недоступен, транспорт ляжет с ретраями). */ + fun btRemoteMac(): String? = BT_REMOTE_MAC } /** diff --git a/app-glasses/src/main/kotlin/pw/binom/viewmate/glasses/HostConnection.kt b/app-glasses/src/main/kotlin/pw/binom/viewmate/glasses/HostConnection.kt index a44a642..f9c9670 100644 --- a/app-glasses/src/main/kotlin/pw/binom/viewmate/glasses/HostConnection.kt +++ b/app-glasses/src/main/kotlin/pw/binom/viewmate/glasses/HostConnection.kt @@ -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() } diff --git a/app-glasses/src/main/kotlin/pw/binom/viewmate/glasses/MainActivity.kt b/app-glasses/src/main/kotlin/pw/binom/viewmate/glasses/MainActivity.kt index c7f5605..03de841 100644 --- a/app-glasses/src/main/kotlin/pw/binom/viewmate/glasses/MainActivity.kt +++ b/app-glasses/src/main/kotlin/pw/binom/viewmate/glasses/MainActivity.kt @@ -4,6 +4,7 @@ import android.content.BroadcastReceiver import android.content.Context import android.content.Intent import android.content.IntentFilter +import android.content.pm.PackageManager import android.hardware.display.DisplayManager import android.os.Build import android.os.Bundle @@ -116,6 +117,7 @@ class MainActivity : BaseEventActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) GlassesApp.instance.mainActivity = this + requestBtPermissions() collectTempleActions() disableTrackpadSounds() WindowCompat.setDecorFitsSystemWindows(window, false) @@ -193,6 +195,16 @@ class MainActivity : BaseEventActivity() { * голосовой ввод (безусловный вызов), Click во время прослушивания — * завершить распознавание, DoubleClick — отмена. */ + private fun requestBtPermissions() { + if (!GlassesConfig.USE_BLUETOOTH) return + val granted = BtGlassesTransport.runtimePermissions().all { + checkSelfPermission(it) == PackageManager.PERMISSION_GRANTED + } + if (granted) return + requestPermissions(BtGlassesTransport.runtimePermissions(), 1008) + log("app", "запрошены Bluetooth-пермишены (SPP): ${BtGlassesTransport.runtimePermissions().joinToString()}") + } + private fun collectTempleActions() { lifecycleScope.launch { repeatOnLifecycle(Lifecycle.State.RESUMED) { diff --git a/app-phone/src/main/AndroidManifest.xml b/app-phone/src/main/AndroidManifest.xml index 38a50ca..ffef1be 100644 --- a/app-phone/src/main/AndroidManifest.xml +++ b/app-phone/src/main/AndroidManifest.xml @@ -7,7 +7,18 @@ + + + + + + + + + + + diff --git a/app-phone/src/main/kotlin/pw/binom/viewmate/phone/BtServerTransport.kt b/app-phone/src/main/kotlin/pw/binom/viewmate/phone/BtServerTransport.kt new file mode 100644 index 0000000..e06c857 --- /dev/null +++ b/app-phone/src/main/kotlin/pw/binom/viewmate/phone/BtServerTransport.kt @@ -0,0 +1,200 @@ +package pw.binom.viewmate.phone + +import android.Manifest +import android.annotation.SuppressLint +import android.bluetooth.BluetoothAdapter +import android.bluetooth.BluetoothDevice +import android.bluetooth.BluetoothServerSocket +import android.bluetooth.BluetoothSocket +import android.content.Context +import android.content.pm.PackageManager +import android.os.Build +import androidx.core.content.ContextCompat +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import pw.binom.viewmate.core.log +import pw.binom.viewmate.core.net.BtTransportConstants +import pw.binom.viewmate.core.net.GlassesServerTransport +import java.io.BufferedReader +import java.io.InputStreamReader +import java.io.OutputStream +import java.util.UUID +import java.util.concurrent.ConcurrentHashMap + +/** + * Bluetooth-транспорт, сторона телефона («сервер») — TASK-transport.md п.3: + * RFCOMM/SPP ([BtTransportConstants.SPP_UUID]), сообщения — строки + * (JSON + перевод строки как разделитель), read-поток — на Dispatchers.IO, + * запись — под синхронизацией на сокете. [connId] = MAC-адрес очков. + * Работает флагом [pw.binom.viewmate.phone.PhoneConfig.USE_BLUETOOTH] + * (дефолт — [WifiServerTransport]). + * @param context — для runtime-проверки BT-пермишенов (Android 12+ / до 12). + */ +class BtServerTransport( + private val context: Context? = null, +) : GlassesServerTransport { + + companion object { + /** + * Runtime-пермишены Bluetooth, зависящие от версии платформы + * (TASK-transport.md п.3, п.5: Android 12+ / до 12): + * 31+ — BLUETOOTH_CONNECT (SCAN — для future startDiscovery), + * ≤30 — BLUETOOTH + BLUETOOTH_ADMIN (опасные, запрашиваются в рантайме). + */ + fun runtimePermissions(): Array = + if (Build.VERSION.SDK_INT >= 31) { + arrayOf(Manifest.permission.BLUETOOTH_CONNECT, Manifest.permission.BLUETOOTH_SCAN) + } else { + arrayOf(Manifest.permission.BLUETOOTH, Manifest.permission.BLUETOOTH_ADMIN) + } + + private fun hasBtPermissions(ctx: Context): Boolean = + runtimePermissions().all { + ContextCompat.checkSelfPermission(ctx, it) == PackageManager.PERMISSION_GRANTED + } + } + + override val name = "bluetooth" + + @Volatile + private var closed = false + @Volatile + private var serverSocket: BluetoothServerSocket? = null + + private val sockets = ConcurrentHashMap() + private val writers = ConcurrentHashMap() + private val readerJobs = ConcurrentHashMap() + private val readerScope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + + /** Бесконечно принимать SPP-соединения очков; без BT/пермишена — ретраи через 3с. */ + override suspend fun accept( + onMessage: suspend (connId: String, json: String) -> Unit, + onConnected: (connId: String) -> Unit, + onDisconnected: (connId: String) -> Unit, + ) { + while (!closed) { + if (serverSocket == null) { + serverSocket = openServerSocket() + if (serverSocket == null) { + log("bt", "SPP-сервер не создан (BT выключен? нет пермишена?), ретрай через ${BtTransportConstants.RETRY_DELAY_MS / 1000}с") + delay(BtTransportConstants.RETRY_DELAY_MS) + continue + } + log("bt", "SPP-сервер запущен: ${BtTransportConstants.SERVICE_NAME} (${BtTransportConstants.SPP_UUID})") + } + try { + val s = withContext(Dispatchers.IO) { serverSocket!!.accept() } ?: break + if (closed) { + runCatching { s.close() } + break + } + val remote: BluetoothDevice = s.remoteDevice + val connId = remote.address + sockets[connId] = s + writers[connId] = s.outputStream + log("bt", "очки подключились: $connId") + onConnected(connId) + readerJobs[connId] = readerScope.launch { + readLines(s, connId, onMessage, onDisconnected) + } + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + log("bt", "accept прерван (${e::class.simpleName}: ${e.message}), ретрай через ${BtTransportConstants.RETRY_DELAY_MS / 1000}с") + runCatching { serverSocket?.close() } + serverSocket = null + delay(BtTransportConstants.RETRY_DELAY_MS) + } + } + cleanupConnections() + readerScope.cancel() + } + + /** SPP-сервер (RFCOMM-канал SPP): без BT-радио/пермишена — null (лечь и ретраиться). */ + @SuppressLint("MissingPermission") + private fun openServerSocket(): BluetoothServerSocket? { + val adapter = BluetoothAdapter.getDefaultAdapter() ?: run { + log("bt", "BT-радио не найдено — SPP недоступен") + return null + } + if (!adapter.isEnabled) { + log("bt", "Bluetooth выключен — включите в настройках Android") + return null + } + val ctx = context + if (ctx != null && !hasBtPermissions(ctx)) { + log("bt", "нет runtime-пермишена Bluetooth: ${runtimePermissions().joinToString(", ")}") + return null + } + return runCatching { + adapter.listenUsingRfcommWithServiceRecord( + BtTransportConstants.SERVICE_NAME, + UUID.fromString(BtTransportConstants.SPP_UUID), + ) + }.onFailure { log("bt", "не удалось создать SPP-сервер: ${it.message}") }.getOrNull() + } + + /** Read-поток (Dispatchers.IO): строки по [BtTransportConstants.NEWLINE] → [onMessage]. */ + private suspend fun readLines( + s: BluetoothSocket, + connId: String, + onMessage: suspend (connId: String, json: String) -> Unit, + onDisconnected: (connId: String) -> Unit, + ) { + val reader = BufferedReader(InputStreamReader(s.inputStream)) + try { + while (!closed) { + val line = withContext(Dispatchers.IO) { reader.readLine() } ?: break + if (line.isNotBlank()) onMessage(connId, line) + } + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + log("bt", "read-поток $connId оборван: ${e.message}") + } finally { + runCatching { reader.close() } + runCatching { s.close() } + sockets.remove(connId) + writers.remove(connId) + readerJobs.remove(connId) + } + if (!closed) { + log("bt", "очки отключились: $connId") + onDisconnected(connId) + } + } + + /** JSON-строка в сокет [connId]: UTF-8 + разделитель, под синхронизацией на сокете. */ + override suspend fun send(connId: String, json: String) { + val out = writers[connId] ?: return + runCatching { + synchronized(out) { + out.write(json.toByteArray(Charsets.UTF_8)) + out.write(BtTransportConstants.NEWLINE.code) + out.flush() + } + }.onFailure { log("bt", "запись на $connId не удалась: ${it.message}") } + } + + override fun close() { + closed = true + runCatching { serverSocket?.close() } + serverSocket = null + cleanupConnections() + readerScope.cancel() + } + + private fun cleanupConnections() { + readerJobs.values.forEach { it.cancel() } + readerJobs.clear() + sockets.values.forEach { runCatching { it.close() } } + sockets.clear() + writers.clear() + } +} diff --git a/app-phone/src/main/kotlin/pw/binom/viewmate/phone/GlassesServer.kt b/app-phone/src/main/kotlin/pw/binom/viewmate/phone/GlassesServer.kt index ead5507..91f9620 100644 --- a/app-phone/src/main/kotlin/pw/binom/viewmate/phone/GlassesServer.kt +++ b/app-phone/src/main/kotlin/pw/binom/viewmate/phone/GlassesServer.kt @@ -1,23 +1,18 @@ package pw.binom.viewmate.phone -import io.ktor.server.application.Application -import io.ktor.server.application.install -import io.ktor.server.cio.CIO -import io.ktor.server.engine.EmbeddedServer -import io.ktor.server.engine.embeddedServer -import io.ktor.server.routing.routing -import io.ktor.server.websocket.DefaultWebSocketServerSession -import io.ktor.server.websocket.WebSockets -import io.ktor.server.websocket.webSocket -import io.ktor.websocket.Frame -import io.ktor.websocket.readText -import io.ktor.websocket.send +import android.content.Context +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch import kotlinx.serialization.decodeFromString import kotlinx.serialization.encodeToString -import pw.binom.viewmate.core.GlassesMode +import pw.binom.viewmate.core.net.GlassesServerTransport import pw.binom.viewmate.core.phone.GlassesSender import pw.binom.viewmate.core.phone.PhoneState import pw.binom.viewmate.core.protocol.DownloadProgress @@ -41,14 +36,18 @@ const val GLASSES_WS_PATH = "/ws/glasses" /** * Менеджер подключённых очков (телефон). Потокобезопасен (ConcurrentHashMap). - * Раздаёт исходящие сообщения всем очкам и обрабатывает входящие - * (Hello → Welcome, жесты/статус/позицию → лог, GlassesOff → пауза, - * DownloadProgress → карта скачивания на очки). + * Сессии адресуются **connId-строками** (UUID — WiFi, MAC-адрес — Bluetooth), + * не WS-объектами (TASK-transport.md п.1): WS-мост — в [WifiServerTransport], + * SPP-сокеты — в [BtServerTransport]. Исходящие — JSON-строкой через + * [GlassesServerTransport.send] (декод протокола — в хабe, не в транспорте). */ class GlassesHub( private val state: PhoneState, + /** Выбранный транспорт (дефолт — [WifiServerTransport]). */ + private val transport: GlassesServerTransport, ) { - private val sessions = ConcurrentHashMap() + /** Сессии: connId → Unit. */ + private val sessions = ConcurrentHashMap() private val _connected = MutableStateFlow(0) val connected: StateFlow = _connected.asStateFlow() @@ -110,32 +109,29 @@ class GlassesHub( @Volatile var sttFactory: (() -> SttStreamer?)? = null - fun add(session: DefaultWebSocketServerSession) { - sessions[session] = Unit + fun add(connId: String) { + sessions[connId] = Unit _connected.value = sessions.size - log("hub", "очки подключились, всего: ${sessions.size}") + log("hub", "очки подключились (connId $connId, всего: ${sessions.size})") } - fun remove(session: DefaultWebSocketServerSession) { - sessions.remove(session) + fun remove(connId: String) { + sessions.remove(connId) _connected.value = sessions.size - log("hub", "очки отключились, всего: ${sessions.size}") + log("hub", "очки отключились (connId $connId, всего: ${sessions.size})") } - /** Отправить сообщение всем подключённым очкам. */ + /** Отправить сообщение всем подключённым очкам (JSON-строкой через [transport]). */ suspend fun broadcast(msg: HostToGlasses) { val text = protocolJson.encodeToString(HostToGlasses.serializer(), msg) - for (session in sessions.keys) { - try { - session.send(text) - } catch (e: Exception) { - log("hub", "не удалось отправить ${msg::class.simpleName}: ${e.message}") - } + for (connId in sessions.keys) { + runCatching { transport.send(connId, text) } + .onFailure { log("hub", "не удалось отправить ${msg::class.simpleName} на $connId: ${it.message}") } } } - /** Обработать входящее текстовое сообщение от очков. */ - suspend fun handle(session: DefaultWebSocketServerSession, text: String) { + /** Обработать входящее текстовое сообщение от очков (JSON-строкой из [transport]). */ + suspend fun handle(connId: String, text: String) { val msg = try { protocolJson.decodeFromString(GlassesToHost.serializer(), text) } catch (e: Exception) { @@ -144,8 +140,8 @@ class GlassesHub( } when (msg) { is Hello -> { - log("glasses", "hello (app=${msg.appVersion}) → welcome") - send(session, Welcome(mode = state.mode, sessionId = state.activeSessionId)) + log("glasses", "hello (app=${msg.appVersion}, connId $connId) → welcome") + send(connId, Welcome(mode = state.mode, sessionId = state.activeSessionId)) } is Gesture -> { @@ -199,80 +195,67 @@ class GlassesHub( is StopStt -> { if (msg.cancel) { log("stt", "распознавание отменено (очки)") - send(session, SttCancel()) + send(connId, SttCancel()) stt?.reset() } else { val full = stt?.finish() ?: "" log("stt", "ВЕСЬ ТЕКСТ: $full") - send(session, SttDone(full)) + send(connId, SttDone(full)) stt?.reset() } } } } - private suspend fun send(session: DefaultWebSocketServerSession, msg: HostToGlasses) { + private suspend fun send(connId: String, msg: HostToGlasses) { val text = protocolJson.encodeToString(HostToGlasses.serializer(), msg) - session.send(text) - } -} - -/** Ktor-модуль телефона: WebSocket-роут для подключения очков. */ -fun Application.glassesServerModule(hub: GlassesHub) { - install(WebSockets) { - pingPeriodMillis = 5_000 - timeoutMillis = 5_000 - maxFrameSize = Long.MAX_VALUE - masking = false - } - routing { - webSocket(GLASSES_WS_PATH) { - hub.add(this) - try { - for (frame in incoming) { - if (frame is Frame.Text) { - val text = frame.readText() - hub.handle(this, text) - } - } - } catch (e: Exception) { - log("server", "WS-ошибка: ${e.message}") - e.printStackTrace() - } finally { - hub.remove(this) - } - } + runCatching { transport.send(connId, text) } + .onFailure { log("hub", "не удалось отправить ${msg::class.simpleName} на $connId: ${it.message}") } } } /** - * WS-сервер телефона (CIO-движок — работает на Android, в отличие от Netty). - * Живёт в приложении (Application), слушает 0.0.0.0:[port]/ws/glasses. + * Сервер связи очков (запускается в PhoneApp). [transport] выбирается флагом + * [useBluetooth] (TASK-transport.md п.4): дефолт — [WifiServerTransport] + * (Ktor CIO, `0.0.0.0:$port/ws/glasses`), Bluetooth — [BtServerTransport] + * (SPP). Поведение по умолчанию (WiFi) не меняется. */ class GlassesServer( private val port: Int = PhoneConfig.SERVER_PORT, private val state: PhoneState = PhoneState(), + /** `false` (дефолт) — WiFi; `true` — Bluetooth SPP. */ + useBluetooth: Boolean = false, + /** Context для runtime-проверки BT-пермишенов (нужен только в [BtServerTransport]). */ + private val btContext: Context? = null, ) { - val hub = GlassesHub(state) + /** Активный транспорт: [WifiServerTransport] (дефолт) или [BtServerTransport]. */ + val transport: GlassesServerTransport = + if (useBluetooth) BtServerTransport(btContext) else WifiServerTransport(port) + + val hub = GlassesHub(state, transport) /** GlassesSender-адаптер поверх хаба (для PhoneActions). */ val sender: GlassesSender = object : GlassesSender { override suspend fun send(msg: HostToGlasses) = hub.broadcast(msg) } - private var server: EmbeddedServer<*, *>? = null + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + private var acceptJob: Job? = null + /** Запустить приём подключений очков ([transport.accept] — бесконечный цикл). */ fun start() { - if (server != null) return - server = embeddedServer(CIO, port = port, host = "0.0.0.0") { - glassesServerModule(hub) + if (acceptJob != null) return + acceptJob = scope.launch { + transport.accept( + onMessage = { connId, json -> hub.handle(connId, json) }, + onConnected = { connId -> hub.add(connId) }, + onDisconnected = { connId -> hub.remove(connId) }, + ) } - server!!.start(wait = false) - log("server", "WS-сервер запущен на 0.0.0.0:$port$GLASSES_WS_PATH") } fun stop() { - server?.stop(1_000, 3_000) - server = null + transport.close() + scope.cancel() } } diff --git a/app-phone/src/main/kotlin/pw/binom/viewmate/phone/MainActivity.kt b/app-phone/src/main/kotlin/pw/binom/viewmate/phone/MainActivity.kt index c47e91a..1609cf2 100644 --- a/app-phone/src/main/kotlin/pw/binom/viewmate/phone/MainActivity.kt +++ b/app-phone/src/main/kotlin/pw/binom/viewmate/phone/MainActivity.kt @@ -1,5 +1,7 @@ package pw.binom.viewmate.phone +import android.Manifest +import android.content.pm.PackageManager import android.graphics.Color import android.os.Bundle import android.view.Gravity @@ -7,6 +9,7 @@ import android.widget.FrameLayout import android.widget.TextView import androidx.activity.ComponentActivity import androidx.activity.compose.setContent +import androidx.core.content.ContextCompat import pw.binom.viewmate.phone.stt.SttDebug import pw.binom.viewmate.phone.agent.LlmDebug import pw.binom.viewmate.phone.ui.ViewMateApp @@ -15,6 +18,7 @@ import java.io.File class MainActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) + requestBtPermissions() setContent { ViewMateApp() } @@ -26,6 +30,23 @@ class MainActivity : ComponentActivity() { } } + /** + * Runtime-запрос Bluetooth-пермишенов (TASK-transport.md п.5): только при + * вкл. SPP ([PhoneConfig.USE_BLUETOOTH]). На 31+ — BLUETOOTH_CONNECT/SCAN, + * на ≤30 — BLUETOOTH/BLUETOOTH_ADMIN. Если уже выданы — молча пропускаем. + */ + private fun requestBtPermissions() { + if (!PhoneConfig.USE_BLUETOOTH) return + val needed = BtServerTransport.runtimePermissions() + val missing = needed.filter { + ContextCompat.checkSelfPermission(this, it) != PackageManager.PERMISSION_GRANTED + } + if (missing.isNotEmpty()) { + requestPermissions(missing.toTypedArray(), 1009) + log("bt", "запрошены Bluetooth-пермишены (SPP): ${missing.joinToString()}") + } + } + /** Smoke Koog-клиента: один вызов llm.binom.pw, ответ в лог [llm]. */ private fun runLlmDebug() { showOverlay("[llm] вызываю llm.binom.pw...") diff --git a/app-phone/src/main/kotlin/pw/binom/viewmate/phone/PhoneApp.kt b/app-phone/src/main/kotlin/pw/binom/viewmate/phone/PhoneApp.kt index c825782..c577428 100644 --- a/app-phone/src/main/kotlin/pw/binom/viewmate/phone/PhoneApp.kt +++ b/app-phone/src/main/kotlin/pw/binom/viewmate/phone/PhoneApp.kt @@ -39,8 +39,13 @@ class PhoneApp : Application() { val state: PhoneState = PhoneState() - /** WS-сервер для очков. */ - val server: GlassesServer = GlassesServer(port = PhoneConfig.SERVER_PORT, state = state) + /** Сервер связи очков: транспорт по [PhoneConfig.USE_BLUETOOTH] — дефолт WiFi (WS), Bluetooth SPP — флагом. */ + val server: GlassesServer = GlassesServer( + port = PhoneConfig.SERVER_PORT, + state = state, + useBluetooth = PhoneConfig.USE_BLUETOOTH, + btContext = this, + ) /** Публикация сервера в локальной сети (mDNS `_viewmate._tcp`) для авто-обнаружения очками. */ val nsd: NsdPublisher by lazy { NsdPublisher(this) } @@ -103,13 +108,21 @@ class PhoneApp : Application() { */ private suspend fun assistantLoop() { for (phrase in assistantChannel) { + log("assistant", "получена фраза: $phrase") val assistant = ensureAssistant() if (assistant == null) { log("assistant", "LLM-ключ не задан — фраза пропущена") continue } server.hub.broadcast(AssistantStateMsg(AssistantState.THINKING)) - val answer = runCatching { assistant.process(phrase) } + val answer = runCatching { + val active = _activeSessionId.value + if (active != null) { + assistant.processInSession(active, phrase) + } else { + assistant.process(phrase) + } + } .onFailure { log("assistant", "ошибка: ${it.message}") } .getOrElse { "Ассистент недоступен: ${it.message}" } server.hub.broadcast(ShowText(answer)) @@ -117,6 +130,36 @@ class PhoneApp : Application() { } } + /** + * Отправка текста в ассистента из чата на телефоне (не с очков). + * Та же очередь, что и голосовые фразы — сериализация LLM сохраняется. + */ + fun sendToAssistant(text: String) { + scope.launch { assistantChannel.send(text) } + } + + /** Активная сессия чата (для UI телефона). null — «последняя» (дефолт). */ + private val _activeSessionId = MutableStateFlow(null) + val activeSessionId: StateFlow = _activeSessionId + + /** Создать новую сессию и сделать её активной. */ + fun newChatSession(): Long { + val id = chatDao.createSession() + _activeSessionId.value = id + return id + } + + /** Переключить активную сессию. null — вернуться к «последней». */ + fun selectChatSession(id: Long?) { + _activeSessionId.value = id + } + + /** Сбросить (очистить сообщения) активной или последней сессии. */ + fun resetChatSession() { + val id = _activeSessionId.value ?: chatDao.latestSessionId() ?: return + chatDao.clearSession(id) + } + /** * Лениво создаёт стриминговое распознавание (модели во filesDir/models — * кладутся через adb push + run-as cp, как в этапе 1). Вызывается из хаба @@ -189,6 +232,9 @@ class PhoneApp : Application() { super.onCreate() instance = this + PlaybackService.start(this) + log("server", "playback-сервис запущен (foreground, WAKE_LOCK)") + server.start() log("server", "WS-сервер запущен на ${PhoneConfig.SERVER_PORT}") nsd.publish() @@ -264,6 +310,7 @@ class PhoneApp : Application() { } override fun onTerminate() { + PlaybackService.stop(this) nsd.unpublish() sttStreamer?.close() server.stop() diff --git a/app-phone/src/main/kotlin/pw/binom/viewmate/phone/PhoneConfig.kt b/app-phone/src/main/kotlin/pw/binom/viewmate/phone/PhoneConfig.kt index b62019f..46d6dc5 100644 --- a/app-phone/src/main/kotlin/pw/binom/viewmate/phone/PhoneConfig.kt +++ b/app-phone/src/main/kotlin/pw/binom/viewmate/phone/PhoneConfig.kt @@ -12,6 +12,13 @@ object PhoneConfig { const val MIRROR_API_KEY = "67KA48ScP0qxRIihmr1srlggIIkRUAG" const val SERVER_PORT = 8080 + /** + * Выбор транспорта очки↔телефон (TASK-transport.md п.4): + * `false` (дефолт) — WiFi ([WifiServerTransport], поведение без изменений); + * `true` — Bluetooth SPP ([BtServerTransport], manual-включение). + */ + const val USE_BLUETOOTH = true + /** Watchdog: пауза звука, если очки молчат дольше этого времени (экран погас, радио спит). */ const val AUDIO_SILENCE_PAUSE_MS = 5_000L diff --git a/app-phone/src/main/kotlin/pw/binom/viewmate/phone/PlaybackService.kt b/app-phone/src/main/kotlin/pw/binom/viewmate/phone/PlaybackService.kt new file mode 100644 index 0000000..2759a85 --- /dev/null +++ b/app-phone/src/main/kotlin/pw/binom/viewmate/phone/PlaybackService.kt @@ -0,0 +1,75 @@ +package pw.binom.viewmate.phone + +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.Service +import android.content.Context +import android.content.Intent +import android.net.wifi.WifiManager +import android.os.IBinder +import android.os.PowerManager +import androidx.core.app.NotificationCompat +import androidx.core.content.ContextCompat + +/** + * Foreground service типа mediaPlayback: держит процесс телефона живым (WS-сервер + + * аудио-плеер) при выключенном экране. Без него HONOR замораживает/убивает процесс + * в фоне → очки теряют связь (видео паузится) и звук встаёт. Является спутником + * WS-сервера: стартует в PhoneApp.onCreate, останавливается в onTerminate. + */ +class PlaybackService : Service() { + companion object { + const val CHANNEL_ID = "viewmate_playback" + const val NOTIFICATION_ID = 1 + + fun start(context: Context) { + ContextCompat.startForegroundService( + context, Intent(context, PlaybackService::class.java) + ) + } + + fun stop(context: Context) { + context.stopService(Intent(context, PlaybackService::class.java)) + } + } + + private var wakeLock: PowerManager.WakeLock? = null + private var wifiLock: WifiManager.WifiLock? = null + + override fun onCreate() { + super.onCreate() + + val channel = NotificationChannel( + CHANNEL_ID, "ViewMate playback", + NotificationManager.IMPORTANCE_LOW, + ).apply { setShowBadge(false) } + getSystemService(NotificationManager::class.java).createNotificationChannel(channel) + + val notification = NotificationCompat.Builder(this, CHANNEL_ID) + .setSmallIcon(android.R.drawable.ic_media_play) + .setContentTitle("ViewMate") + .setContentText("Звук на телефоне при просмотре на очках") + .setOngoing(true) + .build() + startForeground(NOTIFICATION_ID, notification) + + // держим CPU и WiFi живыми при выключенном экране + wakeLock = (getSystemService(PowerManager::class.java) as PowerManager) + .newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "viewmate:audio") + ?.apply { acquire() } + wifiLock = (getApplicationContext().getSystemService(WIFI_SERVICE) as WifiManager) + .createWifiLock(WifiManager.WIFI_MODE_FULL_HIGH_PERF, "viewmate:wifi") + ?.apply { acquire() } + } + + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int = + START_STICKY + + override fun onDestroy() { + wakeLock?.takeIf { it.isHeld }?.release() + wifiLock?.takeIf { it.isHeld }?.release() + super.onDestroy() + } + + override fun onBind(intent: Intent?): IBinder? = null +} \ No newline at end of file diff --git a/app-phone/src/main/kotlin/pw/binom/viewmate/phone/WifiServerTransport.kt b/app-phone/src/main/kotlin/pw/binom/viewmate/phone/WifiServerTransport.kt new file mode 100644 index 0000000..1022c65 --- /dev/null +++ b/app-phone/src/main/kotlin/pw/binom/viewmate/phone/WifiServerTransport.kt @@ -0,0 +1,98 @@ +package pw.binom.viewmate.phone + +import io.ktor.server.application.Application +import io.ktor.server.application.install +import io.ktor.server.cio.CIO +import io.ktor.server.engine.EmbeddedServer +import io.ktor.server.engine.embeddedServer +import io.ktor.server.routing.routing +import io.ktor.server.websocket.DefaultWebSocketServerSession +import io.ktor.server.websocket.WebSockets +import io.ktor.server.websocket.webSocket +import io.ktor.websocket.Frame +import io.ktor.websocket.readText +import io.ktor.websocket.send +import kotlinx.coroutines.delay +import pw.binom.viewmate.core.net.GlassesServerTransport +import java.util.UUID +import java.util.concurrent.ConcurrentHashMap + +/** + * WiFi-транспорт, сторона телефона («сервер») — TASK-transport.md п.2: + * [GlassesServerTransport] на Ktor-CIO-сервере (WebSocket [GLASSES_WS_PATH]). + * Сессии — [connId] (UUID-строки, не WS-объекты): мост между WS-сессией и + * connId живёт здесь, [GlassesHub] знает только строки. + */ +class WifiServerTransport( + private val port: Int = PhoneConfig.SERVER_PORT, +) : GlassesServerTransport { + + override val name = "wifi" + + private val sessionByConn = ConcurrentHashMap() + @Volatile + private var server: EmbeddedServer<*, *>? = null + @Volatile + private var closed = false + + /** Бесконечно принимать очки: старт Ktor-сервера + холд (пока [close]). */ + override suspend fun accept( + onMessage: suspend (connId: String, json: String) -> Unit, + onConnected: (connId: String) -> Unit, + onDisconnected: (connId: String) -> Unit, + ) { + server = embeddedServer(CIO, port = port, host = "0.0.0.0") { + glassesWsModule(onMessage, onConnected, onDisconnected) + } + server!!.start(wait = false) + log("server", "WS-сервер запущен на 0.0.0.0:$port$GLASSES_WS_PATH") + while (!closed) { + delay(500) + } + } + + /** Ktor-модуль: WS-роут очков; сессия адресуется connId (UUID), события — колбэки [accept]. */ + private fun Application.glassesWsModule( + onMessage: suspend (connId: String, json: String) -> Unit, + onConnected: (connId: String) -> Unit, + onDisconnected: (connId: String) -> Unit, + ) { + install(WebSockets) { + pingPeriodMillis = 5_000 + timeoutMillis = 5_000 + maxFrameSize = Long.MAX_VALUE + masking = false + } + routing { + webSocket(GLASSES_WS_PATH) { + val connId = UUID.randomUUID().toString() + sessionByConn[connId] = this + onConnected(connId) + try { + for (frame in incoming) { + if (frame is Frame.Text) { + onMessage(connId, frame.readText()) + } + } + } catch (e: Exception) { + log("server", "WS-ошибка: ${e.message}") + e.printStackTrace() + } finally { + sessionByConn.remove(connId) + onDisconnected(connId) + } + } + } + } + + override suspend fun send(connId: String, json: String) { + sessionByConn[connId]?.send(json) + } + + override fun close() { + closed = true + runCatching { server?.stop(1_000, 3_000) } + server = null + sessionByConn.clear() + } +} diff --git a/app-phone/src/main/kotlin/pw/binom/viewmate/phone/agent/AgentTools.kt b/app-phone/src/main/kotlin/pw/binom/viewmate/phone/agent/AgentTools.kt new file mode 100644 index 0000000..09e5f56 --- /dev/null +++ b/app-phone/src/main/kotlin/pw/binom/viewmate/phone/agent/AgentTools.kt @@ -0,0 +1,53 @@ +package pw.binom.viewmate.phone.agent + +import java.time.Instant +import java.time.ZoneId +import java.time.format.DateTimeFormatter + +/** + * Тул ассистента: локальная функция, которую агент (LLM) может вызвать по + * имени. Результат возвращается строкой и подставляется в историю как + * tool-сообщение (роль [ChatRole.TOOL]). + * + * Добавление нового тула: реализовать [AgentTool], добавить в список + * [AgentTools.ALL]. Агент узнаёт о тулах из системного промпта (описание + * [describe]) — никакой магии. + */ +interface AgentTool { + /** Имя тула (как агент его называет, например `get_current_time`). */ + val name: String + + /** Короткое описание для системного промпта (что делает, что возвращает). */ + val description: String + + /** Вызвать тул. [arguments] — JSON-строка (пустая, если аргументов нет). */ + fun invoke(arguments: String): String +} + +/** Реестр тулов ассистента. */ +object AgentTools { + val ALL: List = listOf(TimeTool) + + /** Описания для системного промпта: «Тул: name — description». */ + fun describeAll(): String = ALL.joinToString("\n") { "Тул: ${it.name} — ${it.description}" } +} + +/** + * Тул времени: возвращает текущие дату, время и часовой пояс в ISO 8601 + * (например `2026-08-22T03:14:05+03:00[Europe/Moscow]`). Локально на + * телефоне — LLM не нужна, время точное. + */ +object TimeTool : AgentTool { + override val name = "get_current_time" + override val description = + "Текущие дата и время с часовым поясом в формате ISO 8601. Аргументы не нужны (пустой JSON {})." + + private val formatter: DateTimeFormatter = + DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ssXXX") + + override fun invoke(arguments: String): String { + val now = Instant.now() + val zone = ZoneId.systemDefault() + return formatter.format(now.atZone(zone)) + } +} diff --git a/app-phone/src/main/kotlin/pw/binom/viewmate/phone/agent/Assistant.kt b/app-phone/src/main/kotlin/pw/binom/viewmate/phone/agent/Assistant.kt index 0ec8bd0..8b6e117 100644 --- a/app-phone/src/main/kotlin/pw/binom/viewmate/phone/agent/Assistant.kt +++ b/app-phone/src/main/kotlin/pw/binom/viewmate/phone/agent/Assistant.kt @@ -7,31 +7,98 @@ import kotlinx.coroutines.withTimeoutOrNull * LLM → ответ. Потоковый режим: каждая распознанная фраза уходит в LLM, * история накапливается в сессии. Синхронный вызов из одной корутины * (сериализация фраз — LLM-вызовы не пересекаются). + * + * Тулы: агент видит их в системном промпте (см. [SYSTEM_PROMPT]); если ему + * нужна тула, он отвечает одной строкой `TOOL_CALL: <имя>` — ассистент + * выполняет тул и возвращает результат как tool-сообщение в истории, + * затем делает второй вызов LLM с результатом. Обычный текст (без маркера) + * возвращается пользователю как финальный ответ. */ class Assistant( private val chatDao: ChatDao, private val chat: suspend (history: List, system: String?) -> String, + private val tools: List = AgentTools.ALL, private val timeoutMs: Long = 30_000, ) { /** - * Обработать фразу: сохранить user-сообщение, вызвать LLM с историей - * сессии (последние 20 сообщений), сохранить ответ. При таймауте LLM — - * ответ с текстом таймаута, в БД пишется ТОЛЬКО user-сообщение. + * Обработать фразу в текущей (последней) сессии: сохранить user-сообщение, + * вызвать LLM с историей сессии (последние 20 сообщений), при вызове тула — + * выполнить и повторить вызов, сохранить ответ. При таймауте LLM — ответ + * с текстом таймаута, в БД пишется ТОЛЬКО user-сообщение. */ suspend fun process(text: String): String { - val sessionId = chatDao.latestSessionId() ?: chatDao.createSession("ассистент") + val sessionId = chatDao.latestSessionId() ?: chatDao.createSession(randomTitle()) chatDao.addMessage(sessionId, role = ChatRole.USER, content = text) + return processInSession(sessionId) + } + + /** Обработать фразу в КОНКРЕТНОЙ сессии [sessionId] (переключение сессий). */ + suspend fun processInSession(sessionId: Long, text: String): String { + chatDao.addMessage(sessionId, role = ChatRole.USER, content = text) + return processInSession(sessionId) + } + + private suspend fun processInSession(sessionId: Long): String { val history = chatDao.messagesForSession(sessionId).takeLast(20) - val answer = withTimeoutOrNull(timeoutMs) { chat(history, SYSTEM_PROMPT) } + var answer = callLlm(history, sessionId) + var lastToolResult: String? = null + // Цикл тулов: до 3 вызовов подряд (защита от зацикливания агента). + repeat(3) { + val toolName = parseToolCall(answer) ?: return answer + val tool = tools.firstOrNull { it.name == toolName } + if (tool == null) { + chatDao.addMessage(sessionId, role = ChatRole.ASSISTANT, content = answer) + return "Тул $toolName не найден" + } + val result = runCatching { tool.invoke("{}") }.getOrElse { "ошибка тула: ${it.message}" } + lastToolResult = result + chatDao.addMessage(sessionId, role = ChatRole.TOOL, content = result, toolCallId = toolName) + logTool(sessionId, toolName, result) + val withResult = chatDao.messagesForSession(sessionId).takeLast(20) + answer = callLlm(withResult, sessionId) + } + // LLM зациклилась на TOOL_CALL — отдаём последний результат тула как ответ, + // чтобы пользователь получил данные, а не сырой маркер. + val toolName = parseToolCall(answer) + return if (toolName != null && lastToolResult != null) { + "TOOL_CALL $toolName (повтор) — $lastToolResult" + } else { + answer + } + } + + private suspend fun callLlm(history: List, sessionId: Long): String { + val answer = withTimeoutOrNull(timeoutMs) { chat(history, systemPrompt()) } ?: return "Ассистент не ответил (таймаут)" - chatDao.addMessage(sessionId, role = ChatRole.ASSISTANT, content = answer) - chatDao.touchSession(sessionId) + if (!isToolCall(answer)) { + chatDao.addMessage(sessionId, role = ChatRole.ASSISTANT, content = answer) + chatDao.touchSession(sessionId) + } return answer } + /** Системный промпт: роль + описания тулов (если есть). */ + private fun systemPrompt(): String { + val base = "Ты — ассистент. Делай то, что говорит пользователь. Отвечай кратко (1-3 предложения), по-русски." + if (tools.isEmpty()) return base + return "$base\n\nЕсли для ответа нужна тула, ответь ровно одной строкой TOOL_CALL: имя_тула (без кавычек, без текста вокруг). После получения результата тула ответь пользователю текстом.\n\n${AgentTools.describeAll()}" + } + companion object { - // TODO(персона): промпт выберет пользователь - const val SYSTEM_PROMPT = - "Ты — локальный голосовой ассистент в AR-очках. Отвечай кратко (1-3 предложения), по-русски, без markdown и эмодзи. Рядом с тобой может идти фильм — вопросы могут быть про него." + /** Простой протокол вызова тула: строка вида `TOOL_CALL: `. */ + private val TOOL_CALL_RE = Regex("""TOOL_CALL\s*:\s*([A-Za-z0-9_]+)""", RegexOption.IGNORE_CASE) + + private fun parseToolCall(answer: String): String? = + TOOL_CALL_RE.find(answer)?.groupValues?.get(1)?.lowercase() + + private fun isToolCall(answer: String): Boolean = parseToolCall(answer) != null + + /** Случайное имя сессии (UUID-подобное, 8 символов). */ + private fun randomTitle(): String = + "сессия-" + (1..8).map { "0123456789abcdef"[kotlin.random.Random.nextInt(16)] }.joinToString("") + + private fun logTool(sessionId: Long, toolName: String, result: String) { + pw.binom.viewmate.core.log("assistant", "тул $toolName (сессия $sessionId) → $result") + } } } diff --git a/app-phone/src/main/kotlin/pw/binom/viewmate/phone/agent/ChatDao.kt b/app-phone/src/main/kotlin/pw/binom/viewmate/phone/agent/ChatDao.kt index 7a6293c..e943bc9 100644 --- a/app-phone/src/main/kotlin/pw/binom/viewmate/phone/agent/ChatDao.kt +++ b/app-phone/src/main/kotlin/pw/binom/viewmate/phone/agent/ChatDao.kt @@ -32,6 +32,25 @@ class ChatDao(private val db: ChatDb) { return cursor.use { c -> if (c.moveToFirst()) c.getLong(0) else null } } + /** Сессия по id, или null, если не существует. */ + fun getSession(id: Long): Session? { + val cursor = db.readableDatabase.rawQuery( + "SELECT id, title, created_at, updated_at FROM sessions WHERE id = ?", + arrayOf(id.toString()), + ) + return cursor.use { c -> if (c.moveToFirst()) c.toSession() else null } + } + + /** Сброс сессии: удалить все сообщения, сессия остаётся пустой. */ + fun clearSession(id: Long) { + db.writableDatabase.delete( + ChatDb.TABLE_MESSAGES, + "session_id = ?", + arrayOf(id.toString()), + ) + touchSession(id) + } + fun renameSession(id: Long, title: String) { db.writableDatabase.update( ChatDb.TABLE_SESSIONS, diff --git a/app-phone/src/main/kotlin/pw/binom/viewmate/phone/agent/LlmClient.kt b/app-phone/src/main/kotlin/pw/binom/viewmate/phone/agent/LlmClient.kt index e0e3c9f..a501333 100644 --- a/app-phone/src/main/kotlin/pw/binom/viewmate/phone/agent/LlmClient.kt +++ b/app-phone/src/main/kotlin/pw/binom/viewmate/phone/agent/LlmClient.kt @@ -1,64 +1,107 @@ package pw.binom.viewmate.phone.agent -import ai.koog.http.client.okhttp.OkHttpKoogHttpClient -import ai.koog.prompt.dsl.prompt -import ai.koog.prompt.executor.clients.openai.OpenAIClientSettings -import ai.koog.prompt.executor.clients.openai.OpenAILLMClient -import ai.koog.prompt.llm.LLMCapability -import ai.koog.prompt.llm.LLModel -import ai.koog.prompt.llm.LLMProvider +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import kotlinx.serialization.Serializable +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.RequestBody.Companion.toRequestBody +import java.util.concurrent.TimeUnit /** - * Базовый LLM-клиент на Koog (JetBrains), OpenAI-совместимый эндпоинт - * https://llm.binom.pw (chatCompletionsPath по умолчанию "v1/chat/completions"). + * LLM-клиент на прямом HTTP (OkHttp) к OpenAI-совместимому эндпоинту + * https://llm.binom.pw/v1/chat/completions. * - * Модель и виртуальный ключ llm.binom.pw даст пользователь (llm.apiKey в local.properties). - * Пустой ключ — клиент создаётся, но вызовы вернут ошибку авторизации (это ожидаемо). + * Коog-клиент (JetBrains) работал с нашей моделью нестабильно: возвращал пустой + * content на длинных историях (после tool-вызовов) — см. заметку + * /root/notes/2026-08-22-viewmate-night.md. Прямой вызов проверен curl'ом: + * модель Qwen3.8-27B-NVFP4 отвечает стабильно, тул-цикл работает. * - * Класс НЕ singleton и не создаётся в Application: ассистент создаст его при старте сессии. + * Модель и виртуальный ключ llm.binom.pw — из local.properties (llm.apiKey/llm.model). */ class LlmClient( private val apiKey: String, private val baseUrl: String = "https://llm.binom.pw", - private val model: String, // модель укажет пользователь + private val model: String, ) { + private val json = Json { ignoreUnknownKeys = true; encodeDefaults = true } - private val client: OpenAILLMClient = OpenAILLMClient( - apiKey = apiKey, - settings = OpenAIClientSettings(baseUrl = baseUrl), - httpClientFactory = OkHttpKoogHttpClient.Factory(), + private val http = OkHttpClient.Builder() + .connectTimeout(15, TimeUnit.SECONDS) + .readTimeout(60, TimeUnit.SECONDS) + .writeTimeout(30, TimeUnit.SECONDS) + .build() + + @Serializable + private data class ChatMessageDto( + val role: String, + val content: String, ) - private val llmModel: LLModel = LLModel( - provider = LLMProvider.OpenAI, - id = model, - capabilities = listOf( - LLMCapability.Completion, - LLMCapability.OpenAIEndpoint.Completions, - ), + @Serializable + private data class ChatRequest( + val model: String, + val messages: List, + val max_tokens: Int = 1024, + val temperature: Double = 0.3, ) + @Serializable + private data class ChatResponse( + val choices: List = emptyList(), + val error: ApiError? = null, + ) { + @Serializable + data class Choice(val message: Message = Message()) + @Serializable + data class Message(val content: String? = null) + @Serializable + data class ApiError(val message: String? = null) + } + /** * Отправить историю сообщений и вернуть текстовый ответ (без стриминга). - * System-сообщение, если задано, добавляется первым. + * System-сообщение, если задано, добавляется первым. TOOL-сообщения — + * как user-текст «Результат тула : » (в Koog не было tool-роли). */ suspend fun chat(messages: List, system: String? = null): String { - val prompt = prompt("assistant-chat") { - if (system != null) { - this.system(system) - } - for (msg in messages) { - when (msg.role) { - ChatRole.SYSTEM -> if (msg.content.isNotBlank()) this.system(msg.content) - ChatRole.USER -> if (msg.content.isNotBlank()) user(msg.content) - ChatRole.ASSISTANT -> if (msg.content.isNotBlank()) assistant(msg.content) - // TODO(assistant): в Koog нет отдельной tool-роли — результаты инструментов - // моделируются как MessagePart.Tool.Result внутри user-сообщения, но для этого - // нужен tool-name, которого в ChatMessage нет. Пропускаем (базовый клиент). - ChatRole.TOOL -> Unit + val dto = ArrayList() + if (system != null) dto += ChatMessageDto("system", system) + for (msg in messages) { + when (msg.role) { + ChatRole.SYSTEM -> if (msg.content.isNotBlank()) dto += ChatMessageDto("system", msg.content) + ChatRole.USER -> if (msg.content.isNotBlank()) dto += ChatMessageDto("user", msg.content) + ChatRole.ASSISTANT -> if (msg.content.isNotBlank()) dto += ChatMessageDto("assistant", msg.content) + ChatRole.TOOL -> if (msg.content.isNotBlank()) { + dto += ChatMessageDto("user", "Результат тула ${msg.toolCallId ?: "?"}: ${msg.content}") } } } - return client.execute(prompt, llmModel).textContent() + if (dto.none { it.role == "user" }) dto += ChatMessageDto("user", "Продолжай.") + + val body = json.encodeToString(ChatRequest.serializer(), ChatRequest(model = model, messages = dto)) + val request = Request.Builder() + .url("$baseUrl/v1/chat/completions") + .header("Authorization", "Bearer $apiKey") + .header("Content-Type", "application/json") + .post(body.toRequestBody("application/json".toMediaType())) + .build() + + return withContext(Dispatchers.IO) { + http.newCall(request).execute().use { resp -> + val raw = resp.body?.string().orEmpty() + if (!resp.isSuccessful) { + val err = runCatching { json.decodeFromString(raw).error?.message }.getOrNull() + throw IllegalStateException("HTTP ${resp.code}: ${err ?: raw.take(200)}") + } + val text = runCatching { + json.decodeFromString(raw).choices.firstOrNull()?.message?.content + }.getOrNull() + text?.trim() ?: "" + } + } } } diff --git a/app-phone/src/main/kotlin/pw/binom/viewmate/phone/ui/ChatScreen.kt b/app-phone/src/main/kotlin/pw/binom/viewmate/phone/ui/ChatScreen.kt index eaff399..b0c0b12 100644 --- a/app-phone/src/main/kotlin/pw/binom/viewmate/phone/ui/ChatScreen.kt +++ b/app-phone/src/main/kotlin/pw/binom/viewmate/phone/ui/ChatScreen.kt @@ -1,35 +1,222 @@ package pw.binom.viewmate.phone.ui +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Button +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Text +import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import pw.binom.viewmate.phone.PhoneApp +import pw.binom.viewmate.phone.agent.ChatRole +import pw.binom.viewmate.phone.agent.Session -/** Экран 4 — Чат с ассистентом (заглушка: отдельный этап, Hermes-интеграция). */ +/** + * Экран «Чат с Порфирием»: история из SQLite, ввод текста, отправка в + * локального ассистента (та же очередь, что и голос с очков). Управление + * сессиями: новая, переключение (выпадающий список), сброс текущей. + */ @Composable fun ChatScreen() { - Column( - modifier = Modifier.fillMaxSize().padding(24.dp), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = androidx.compose.foundation.layout.Arrangement.Center, + val app = PhoneApp.instance + val scope = rememberCoroutineScope() + + var sessions by remember { mutableStateOf>(emptyList()) } + var messages by remember { mutableStateOf>>(emptyList()) } + var input by remember { mutableStateOf("") } + var sessionMenu by remember { mutableStateOf(false) } + var activeId by remember { mutableStateOf(null) } + val listState = rememberLazyListState() + + // Загрузка списка сессий и сообщений при старте и при смене активной. + LaunchedEffect(activeId) { + val dao = app.chatDao + val (sess, msgs) = withContext(Dispatchers.IO) { + val all = dao.listSessions() + val target = activeId ?: all.firstOrNull()?.id + val history = target?.let { dao.messagesForSession(it) } ?: emptyList() + all to history.map { it.role to it.content } + } + sessions = sess + activeId = activeId ?: sess.firstOrNull()?.id + messages = msgs + } + + // Подписка на смену активной сессии извне (кнопки в PhoneApp). + val externalActive by app.activeSessionId.collectAsState() + LaunchedEffect(externalActive) { + if (externalActive != null && externalActive != activeId) { + activeId = externalActive + } + } + + // Автопрокрутка вниз при новых сообщениях. + LaunchedEffect(messages.size) { + if (messages.isNotEmpty()) listState.animateScrollToItem(messages.lastIndex) + } + + // Периодическая перезагрузка сообщений из БД (ответ ассистента приходит + // асинхронно — лента должна подхватывать его без ручного обновления). + LaunchedEffect(activeId) { + while (true) { + delay(2_000) + val id = activeId + if (id != null) { + val fresh = withContext(Dispatchers.IO) { app.chatDao.messagesForSession(id).map { it.role to it.content } } + if (fresh != messages) messages = fresh + } + } + } + + Column(modifier = Modifier.fillMaxSize().padding(12.dp)) { + // Шапка: название + управление сессиями + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + text = "Порфирий", + color = PhoneText, + fontSize = 18.sp, + fontWeight = FontWeight.Bold, + modifier = Modifier.weight(1f), + ) + // Переключение сессий + Box { + TextButton(onClick = { sessionMenu = true }) { + Text( + text = sessions.firstOrNull { it.id == activeId }?.title ?: "сессия…", + color = PhoneAccent, + fontSize = 12.sp, + ) + } + DropdownMenu(expanded = sessionMenu, onDismissRequest = { sessionMenu = false }) { + sessions.forEach { s -> + DropdownMenuItem( + text = { Text(s.title, fontSize = 12.sp) }, + onClick = { + activeId = s.id + app.selectChatSession(s.id) + sessionMenu = false + }, + ) + } + DropdownMenuItem( + text = { Text("+ новая сессия", fontSize = 12.sp, color = PhoneAccent) }, + onClick = { + activeId = app.newChatSession() + sessionMenu = false + }, + ) + } + } + // Сброс текущей + TextButton(onClick = { + app.resetChatSession() + activeId?.let { id -> + val history = app.chatDao.messagesForSession(id).map { it.role to it.content } + messages = history + } + }) { + Text("сброс", color = PhoneMuted, fontSize = 12.sp) + } + } + + Spacer(Modifier.height(4.dp)) + + // Лента сообщений + Box(modifier = Modifier.weight(1f).fillMaxWidth()) { + if (messages.isEmpty()) { + Text( + text = "Спроси что-нибудь — например, «сколько сейчас время?»", + color = PhoneMuted, + fontSize = 13.sp, + modifier = Modifier.padding(16.dp), + ) + } else { + LazyColumn(state = listState, modifier = Modifier.fillMaxSize()) { + items(messages) { (role, content) -> + MessageBubble(role = role, text = content) + } + } + } + } + + Spacer(Modifier.height(8.dp)) + + // Ввод + Row(verticalAlignment = Alignment.CenterVertically) { + OutlinedTextField( + value = input, + onValueChange = { input = it }, + modifier = Modifier.weight(1f), + placeholder = { Text("Сообщение…", fontSize = 14.sp, color = PhoneMuted) }, + textStyle = MaterialTheme.typography.bodyMedium.copy(color = PhoneText), + ) + Spacer(Modifier.width(8.dp)) + Button( + onClick = { + val text = input.trim() + if (text.isEmpty()) return@Button + input = "" + messages = messages + (ChatRole.USER to text) + app.sendToAssistant(text) + }, + ) { + Text("➤", fontSize = 16.sp) + } + } + } +} + +/** Пузырь сообщения: user — справа акцентный, assistant — слева карточка. */ +@Composable +private fun MessageBubble(role: ChatRole, text: String) { + val isUser = role == ChatRole.USER + Row( + modifier = Modifier.fillMaxWidth().padding(vertical = 3.dp), + horizontalArrangement = if (isUser) Arrangement.End else Arrangement.Start, ) { Text( - text = "Чат с Порфирием", + text = text, color = PhoneText, - fontSize = 18.sp, - fontWeight = FontWeight.Bold, - ) - Text( - text = "в разработке (следующий этап)", - color = PhoneMuted, - fontSize = 13.sp, - modifier = Modifier.padding(top = 8.dp), + fontSize = 14.sp, + modifier = Modifier + .padding(horizontal = 10.dp, vertical = 6.dp) + .background( + color = if (isUser) PhoneSurface else PhoneCard, + shape = RoundedCornerShape(10.dp), + ) + .padding(horizontal = 10.dp, vertical = 6.dp), ) } } diff --git a/app-phone/src/test/kotlin/pw/binom/viewmate/phone/GlassesServerTest.kt b/app-phone/src/test/kotlin/pw/binom/viewmate/phone/GlassesServerTest.kt index 0083b7f..a1641da 100644 --- a/app-phone/src/test/kotlin/pw/binom/viewmate/phone/GlassesServerTest.kt +++ b/app-phone/src/test/kotlin/pw/binom/viewmate/phone/GlassesServerTest.kt @@ -2,7 +2,6 @@ package pw.binom.viewmate.phone import io.ktor.client.plugins.websocket.WebSockets import io.ktor.client.plugins.websocket.webSocket -import io.ktor.server.testing.testApplication import io.ktor.websocket.Frame import io.ktor.websocket.readText import io.ktor.websocket.send @@ -30,17 +29,34 @@ import kotlin.test.assertEquals import kotlin.test.assertIs import kotlin.test.fail +/** + * Интеграционные тесты хаба поверх реального [WifiServerTransport] (Ktor CIO): + * поднимаем сервер на свободном порту, ходим WS-клиентом, проверяем + * handshake/жесты/бродкаст/статусы (TASK-transport.md п.5: «тесты зелёные»). + */ class GlassesServerTest { private val json = protocolJson - @Test - fun helloGetsWelcome() = testApplication { - val hub = GlassesHub(PhoneState()) - application { glassesServerModule(hub) } - val client = createClient { install(WebSockets) } + /** Поднять [GlassesServer] на случайном порту и вернуть его + порт. */ + private fun startServer(): Pair { + val port = freePort() + val server = GlassesServer(port = port, state = PhoneState(), useBluetooth = false) + server.start() + return server to port + } - client.webSocket(GLASSES_WS_PATH) { + private fun freePort(): Int { + val s = java.net.ServerSocket(0) + val p = s.localPort + s.close() + return p + } + + @Test + fun helloGetsWelcome() = runServer { port, hub -> + val client = wsClient() + client.webSocket("ws://127.0.0.1:$port$GLASSES_WS_PATH") { send(json.encodeToString(GlassesToHost.serializer(), Hello(appVersion = "0.1.0"))) val frame = incoming.receive() as Frame.Text val welcome = json.decodeFromString(HostToGlasses.serializer(), frame.readText()) @@ -50,12 +66,9 @@ class GlassesServerTest { } @Test - fun gestureIsProcessed() = testApplication { - val hub = GlassesHub(PhoneState()) - application { glassesServerModule(hub) } - val client = createClient { install(WebSockets) } - - client.webSocket(GLASSES_WS_PATH) { + fun gestureIsProcessed() = runServer { port, hub -> + val client = wsClient() + client.webSocket("ws://127.0.0.1:$port$GLASSES_WS_PATH") { send(json.encodeToString(GlassesToHost.serializer(), Gesture(gesture = "DoubleClick"))) send(json.encodeToString(GlassesToHost.serializer(), GlassesStatus(70, 10.5, 32.0))) } @@ -64,28 +77,22 @@ class GlassesServerTest { } @Test - fun connectedCounterTracksSessions() = testApplication { - val hub = GlassesHub(PhoneState()) - application { glassesServerModule(hub) } - val client = createClient { install(WebSockets) } - - client.webSocket(GLASSES_WS_PATH) { } + fun connectedCounterTracksSessions() = runServer { port, hub -> + val client = wsClient() + client.webSocket("ws://127.0.0.1:$port$GLASSES_WS_PATH") { } awaitTrue { hub.connected.value == 0 } } @Test - fun broadcastReachesAllGlasses() = testApplication { - val hub = GlassesHub(PhoneState()) - application { glassesServerModule(hub) } - val client = createClient { install(WebSockets) } - + fun broadcastReachesAllGlasses() = runServer { port, hub -> + val client = wsClient() coroutineScope { val opened = Channel(2) val received = Channel(2) val jobs = (1..2).map { launch { - client.webSocket(GLASSES_WS_PATH) { + client.webSocket("ws://127.0.0.1:$port$GLASSES_WS_PATH") { opened.send(Unit) while (true) { val frame = incoming.receive() @@ -115,12 +122,9 @@ class GlassesServerTest { } @Test - fun downloadProgressUpdatesHubPerFileMap() = testApplication { - val hub = GlassesHub(PhoneState()) - application { glassesServerModule(hub) } - val client = createClient { install(WebSockets) } - - client.webSocket(GLASSES_WS_PATH) { + fun downloadProgressUpdatesHubPerFileMap() = runServer { port, hub -> + val client = wsClient() + client.webSocket("ws://127.0.0.1:$port$GLASSES_WS_PATH") { send(json.encodeToString(GlassesToHost.serializer(), DownloadProgress("i1", "video.mkv", 45, 450, 1000, DownloadPhase.DOWNLOADING))) send(json.encodeToString(GlassesToHost.serializer(), DownloadProgress("i1", "audio-0.ogg", 100, 100, 100, DownloadPhase.DONE))) } @@ -131,12 +135,9 @@ class GlassesServerTest { } @Test - fun extendedGlassesStatusUpdatesHubFields() = testApplication { - val hub = GlassesHub(PhoneState()) - application { glassesServerModule(hub) } - val client = createClient { install(WebSockets) } - - client.webSocket(GLASSES_WS_PATH) { + fun extendedGlassesStatusUpdatesHubFields() = runServer { port, hub -> + val client = wsClient() + client.webSocket("ws://127.0.0.1:$port$GLASSES_WS_PATH") { send( json.encodeToString( GlassesToHost.serializer(), @@ -163,6 +164,21 @@ class GlassesServerTest { assertEquals("0.2.0", hub.appVersion.value) } + // ---------- помощники ---------- + + private inline fun runServer(crossinline block: suspend (port: Int, hub: GlassesHub) -> Unit) { + val (server, port) = startServer() + try { + kotlinx.coroutines.runBlocking { block(port, server.hub) } + } finally { + server.stop() + } + } + + private fun wsClient() = io.ktor.client.HttpClient { + install(WebSockets) + } + private suspend fun awaitTrue(timeoutMs: Long = 10_000, condition: () -> Boolean) { val deadline = System.currentTimeMillis() + timeoutMs while (!condition()) { diff --git a/app-phone/src/test/kotlin/pw/binom/viewmate/phone/agent/AgentToolsTest.kt b/app-phone/src/test/kotlin/pw/binom/viewmate/phone/agent/AgentToolsTest.kt new file mode 100644 index 0000000..87f5d15 --- /dev/null +++ b/app-phone/src/test/kotlin/pw/binom/viewmate/phone/agent/AgentToolsTest.kt @@ -0,0 +1,38 @@ +package pw.binom.viewmate.phone.agent + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +/** + * Тесты тулов ассистента — чистая логика без БД и Android. + */ +class AgentToolsTest { + + @Test + fun `тул времени возвращает ISO 8601 с датой и временем`() { + val result = TimeTool.invoke("{}") + // 2026-08-22T03:14:05+03:00 — содержит дату, время и смещение + assertTrue( + result.matches(Regex("""\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}[+-]\d{2}:\d{2}""")), + "не ISO 8601: $result", + ) + assertTrue(result.startsWith("20"), "год не 20xx: $result") + } + + @Test + fun `реестр содержит тулу времени с описанием`() { + assertTrue(AgentTools.ALL.any { it.name == "get_current_time" }) + val desc = AgentTools.describeAll() + assertTrue(desc.contains("get_current_time")) + assertTrue(desc.contains("ISO 8601")) + } + + @Test + fun `тула времени не зависит от аргументов`() { + // Пустой JSON и мусор — один результат (тула без параметров) + val a = TimeTool.invoke("{}") + val b = TimeTool.invoke("{\"x\":1}") + assertEquals(a.take(19), b.take(19)) // одинаковые дата и время в пределах секунды + } +} diff --git a/lib-core/src/commonMain/kotlin/pw/binom/viewmate/core/net/BtTransportConstants.kt b/lib-core/src/commonMain/kotlin/pw/binom/viewmate/core/net/BtTransportConstants.kt new file mode 100644 index 0000000..6a955f9 --- /dev/null +++ b/lib-core/src/commonMain/kotlin/pw/binom/viewmate/core/net/BtTransportConstants.kt @@ -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 +} diff --git a/lib-core/src/commonMain/kotlin/pw/binom/viewmate/core/net/GlassesTransport.kt b/lib-core/src/commonMain/kotlin/pw/binom/viewmate/core/net/GlassesTransport.kt new file mode 100644 index 0000000..68df499 --- /dev/null +++ b/lib-core/src/commonMain/kotlin/pw/binom/viewmate/core/net/GlassesTransport.kt @@ -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() +} diff --git a/lib-core/src/commonMain/kotlin/pw/binom/viewmate/core/net/GlassesWsClient.kt b/lib-core/src/commonMain/kotlin/pw/binom/viewmate/core/net/GlassesWsClient.kt index a5551e1..9251758 100644 --- a/lib-core/src/commonMain/kotlin/pw/binom/viewmate/core/net/GlassesWsClient.kt +++ b/lib-core/src/commonMain/kotlin/pw/binom/viewmate/core/net/GlassesWsClient.kt @@ -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://: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() } diff --git a/lib-core/src/commonMain/kotlin/pw/binom/viewmate/core/net/WifiGlassesTransport.kt b/lib-core/src/commonMain/kotlin/pw/binom/viewmate/core/net/WifiGlassesTransport.kt new file mode 100644 index 0000000..a077739 --- /dev/null +++ b/lib-core/src/commonMain/kotlin/pw/binom/viewmate/core/net/WifiGlassesTransport.kt @@ -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() + } +} diff --git a/lib-core/src/jvmTest/kotlin/pw/binom/viewmate/core/net/GlassesWsClientTest.kt b/lib-core/src/jvmTest/kotlin/pw/binom/viewmate/core/net/GlassesWsClientTest.kt index 0b5eff2..ed19e1d 100644 --- a/lib-core/src/jvmTest/kotlin/pw/binom/viewmate/core/net/GlassesWsClientTest.kt +++ b/lib-core/src/jvmTest/kotlin/pw/binom/viewmate/core/net/GlassesWsClientTest.kt @@ -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() + /** Входящие JSON-строки (транспорт — независим от протокола). */ + val receivedRaw = ConcurrentLinkedQueue() private val sessions = ConcurrentLinkedQueue() 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() + val messages = ConcurrentLinkedQueue() val connected = CompletableDeferred() 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().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() 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().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() + val messages = ConcurrentLinkedQueue() val connected = CompletableDeferred() 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().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() + val connected = CompletableDeferred() + 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 = {}, ) }