Ассистент Порфирий: тула времени, сессии, чат-UI, голосовой цикл; BT-транспорт очки↔телефон

- BT (RFCOMM SPP 00001105) вместо WiFi: BtGlassesTransport/BtServerTransport, реконнект 3с
- PlaybackService (foreground, WAKE_LOCK) против морозов HONOR
- Ассистент: AgentTools (TimeTool ISO 8601), TOOL_CALL-протокол, сессии SQLite (UUID)
- Чат-UI: создание/сброс/переключение сессий, автообновление ленты 2с
- LlmClient на прямом OkHttp (Koog давал пустой content на длинных историях)
- Голосовой цикл: тройной тап на очках → STT → активная сессия → ShowText
- Тесты: AgentToolsTest (3), GlassesServerTest переписан (6) — зелёные
This commit is contained in:
2026-08-22 16:31:04 +03:00
parent adfe376dd4
commit bbf7a020d4
28 changed files with 1802 additions and 342 deletions
+3
View File
@@ -0,0 +1,3 @@
# >>> cortexkit:magic-context
magic-context/
# <<< cortexkit:magic-context
+103
View File
@@ -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<DefaultWebSocketServerSession, Unit>, 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`, прогнать тесты, показать результат.
+117 -91
View File
@@ -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
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />
<uses-permission android:name="android.permission.WAKE_LOCK" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
```
Внутри `<application>` — сервис:
```xml
<service
android:name=".PlaybackService"
android:exported="false"
android:foregroundServiceType="mediaPlayback" />
```
### 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<Path, Pair<Int, Int>>()` — сохранённые позиции по пути
- в `openSeries`/`openSeason`/`openFolder`: **сначала** `savedScroll[path] = scrollIndex to scrollOffset`, **потом** менять path
- в `back()`: после смены path — восстановить `(scrollIndex, scrollOffset) = savedScroll[path] ?: (0,0)`
- методы `setScroll(index, offset)` (для snapshotFlow из UI) и `getSavedScroll(): Pair<Int,Int>` (текущий 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 <phone> shell input keyevent 26` (экран телефона в сон) →
проверить: звук играет, видео на очках играет (позиция растёт в логах),
сервис жив: `adb shell dumpsys activity services | grep -A3 PlaybackService`.
+7
View File
@@ -9,6 +9,13 @@
<uses-permission
android:name="android.permission.NEARBY_WIFI_DEVICES"
android:usesPermissionFlags="neverForLocation" />
<!-- Bluetooth SPP-транспорт (включается флагом GlassesConfig.USE_BLUETOOTH):
Android 12/13 — runtime BLUETOOTH_CONNECT/SCAN; до 12 — опасные
BLUETOOTH/BLUETOOTH_ADMIN (запрашиваются на запуске). -->
<uses-permission android:name="android.permission.BLUETOOTH" android:maxSdkVersion="30" />
<uses-permission android:name="android.permission.BLUETOOTH_ADMIN" android:maxSdkVersion="30" />
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
<uses-permission android:name="android.permission.BLUETOOTH_SCAN" />
<application
android:name=".GlassesApp"
@@ -0,0 +1,189 @@
package pw.binom.viewmate.glasses
import android.Manifest
import android.annotation.SuppressLint
import android.bluetooth.BluetoothAdapter
import android.bluetooth.BluetoothDevice
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.isActive
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.GlassesTransport
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]): [connect] — бесконечный
* цикл подключения с реконнектом через [BtTransportConstants.RETRY_DELAY_MS]
* (цикл живёт внутри транспорта), строки-сообщения (JSON + перевод строки —
* разделитель) читаются на Dispatchers.IO, запись — под синхронизацией на
* сокете. Адрес телефона берётся из [GlassesConfig.btRemoteMac].
* Включается флагом [GlassesConfig.USE_BLUETOOTH] (дефолт — WiFi).
*/
class BtGlassesTransport(
private val context: Context,
) : GlassesTransport {
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<String> =
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
}
}
@@ -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
}
/**
@@ -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()
}
@@ -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) {
+16
View File
@@ -7,7 +7,18 @@
<uses-permission
android:name="android.permission.NEARBY_WIFI_DEVICES"
android:usesPermissionFlags="neverForLocation" />
<!-- Bluetooth SPP-транспорт (включается флагом PhoneConfig.USE_BLUETOOTH):
Android 12/13 — runtime BLUETOOTH_CONNECT/SCAN; до 12 — опасные
BLUETOOTH/BLUETOOTH_ADMIN (запрашиваются на запуске). -->
<uses-permission android:name="android.permission.BLUETOOTH" android:maxSdkVersion="30" />
<uses-permission android:name="android.permission.BLUETOOTH_ADMIN" android:maxSdkVersion="30" />
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
<uses-permission android:name="android.permission.BLUETOOTH_SCAN" />
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />
<uses-permission android:name="android.permission.WAKE_LOCK" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<application
android:name=".PhoneApp"
@@ -24,5 +35,10 @@
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<service
android:name=".PlaybackService"
android:exported="false"
android:foregroundServiceType="mediaPlayback" />
</application>
</manifest>
@@ -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<String> =
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<String, BluetoothSocket>()
private val writers = ConcurrentHashMap<String, OutputStream>()
private val readerJobs = ConcurrentHashMap<String, kotlinx.coroutines.Job>()
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()
}
}
@@ -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<DefaultWebSocketServerSession, Unit>()
/** Сессии: connId → Unit. */
private val sessions = ConcurrentHashMap<String, Unit>()
private val _connected = MutableStateFlow(0)
val connected: StateFlow<Int> = _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()
}
}
@@ -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...")
@@ -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<Long?>(null)
val activeSessionId: StateFlow<Long?> = _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()
@@ -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
@@ -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
}
@@ -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<String, DefaultWebSocketServerSession>()
@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()
}
}
@@ -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<AgentTool> = 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))
}
}
@@ -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<ChatMessage>, system: String?) -> String,
private val tools: List<AgentTool> = 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<ChatMessage>, 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: <name>`. */
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")
}
}
}
@@ -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,
@@ -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<ChatMessageDto>,
val max_tokens: Int = 1024,
val temperature: Double = 0.3,
)
@Serializable
private data class ChatResponse(
val choices: List<Choice> = 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-текст «Результат тула <name>: <content>» (в Koog не было tool-роли).
*/
suspend fun chat(messages: List<ChatMessage>, 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<ChatMessageDto>()
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<ChatResponse>(raw).error?.message }.getOrNull()
throw IllegalStateException("HTTP ${resp.code}: ${err ?: raw.take(200)}")
}
val text = runCatching {
json.decodeFromString<ChatResponse>(raw).choices.firstOrNull()?.message?.content
}.getOrNull()
text?.trim() ?: ""
}
}
}
}
@@ -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<List<Session>>(emptyList()) }
var messages by remember { mutableStateOf<List<Pair<ChatRole, String>>>(emptyList()) }
var input by remember { mutableStateOf("") }
var sessionMenu by remember { mutableStateOf(false) }
var activeId by remember { mutableStateOf<Long?>(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),
)
}
}
@@ -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<GlassesServer, Int> {
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<Unit>(2)
val received = Channel<HostToGlasses>(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()) {
@@ -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)) // одинаковые дата и время в пределах секунды
}
}
@@ -0,0 +1,20 @@
package pw.binom.viewmate.core.net
/**
* Константы Bluetooth-транспорта (SPP/RFCOMM) — TASK-transport.md п.3/п.4.
* Общие для стороны очков (клиент) и телефона (сервер): UUID-сервиса SPP,
* имя сервиса, разделитель строк-сообщений, период реконнекта.
*/
object BtTransportConstants {
/** UUID SPP-сервиса (Serial Port Profile, стандартный Bluetooth SIG). */
const val SPP_UUID = "00001101-0000-1000-8000-00805F9B34FB"
/** Имя SPP-сервиса (RFCOMM-заголовок + человекочитаемый лог). */
const val SERVICE_NAME = "viewmate-spp"
/** Разделитель сообщений (JSON + перевод строки) в SPP. */
const val NEWLINE: Char = '\n'
/** Задержка (мс) между попытками реконнекta BT (оба конца — одинаковый цикл). */
const val RETRY_DELAY_MS: Long = 3_000L
}
@@ -0,0 +1,63 @@
package pw.binom.viewmate.core.net
/**
* Транспорт связи очки↔телефон, сторона очков («клиент») — TASK-transport.md п.1.
* Сообщения ходят **JSON-строками**: декод/кодинг конвертов [HostToGlasses]/
* [GlassesToHost] делает вызывающий (HostConnection на очках, GlassesHub на
* телефоне) — транспорт остаётся независимым от протокола.
*
* Реализации: [WifiGlassesTransport] (WebSocket, дефолт) и BtGlassesTransport
* (Bluetooth SPP, флагом).
*/
interface GlassesTransport {
/** Название транспорта: `wifi` / `bluetooth`. */
val name: String
/**
* Подключиться к телефону: бесконечный цикл реконнекта (ретрай через 3с при
* обрыве) живёт внутри реализации, в корутине вызывающего.
*
* @param onMessage входящее JSON-сообщение (конверт HostToGlasses, строкой).
*/
suspend fun connect(
onMessage: suspend (String) -> Unit,
onConnected: suspend () -> Unit,
onDisconnected: suspend () -> Unit,
)
/** Отправить JSON-сообщение (конверт GlassesToHost) на телефон. Без активного соединения — no-op. */
suspend fun send(json: String)
/** Закрыть транспорт: остановить цикл реконнекта и активное соединение. */
fun close()
}
/**
* Транспорт приёма соединений, сторона телефона («сервер») — TASK-transport.md п.1.
* [accept] выполняется бесконечно: принимает подключения очков и отдаёт события
* по строковым [connId] — не по объектам сокета/сессии (требование ТЗ:
* «сессии — connId-строки»). Сообщения — JSON-строки, декод — не в его
* заботе (запускающий: GlassesHub).
*/
interface GlassesServerTransport {
/** Название транспорта: `wifi` / `bluetooth`. */
val name: String
/**
* Бесконечно принимать подключения очков (в корутине вызывающего).
* @param onMessage входящее JSON-сообщение (конверт GlassesToHost) из соединения [connId].
* @param onConnected установлено новое соединение [connId].
* @param onDisconnected соединение [connId] оборвано.
*/
suspend fun accept(
onMessage: suspend (connId: String, json: String) -> Unit,
onConnected: (connId: String) -> Unit,
onDisconnected: (connId: String) -> Unit,
)
/** Отправить JSON-сообщение конкретному соединению [connId]. Соединения нет — no-op. */
suspend fun send(connId: String, json: String)
/** Закрыть транспорт: перестать принимать и закрыть активные соединения. */
fun close()
}
@@ -10,23 +10,21 @@ import io.ktor.websocket.send
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.cancel
import kotlinx.coroutines.delay
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.encodeToString
import pw.binom.viewmate.core.log
import pw.binom.viewmate.core.media.defaultWsClient
import pw.binom.viewmate.core.protocol.GlassesToHost
import pw.binom.viewmate.core.protocol.HostToGlasses
import pw.binom.viewmate.core.protocol.protocolJson
/**
* WS-клиент очков: подключается к хосту (телефону), шлёт [GlassesToHost],
* принимает [HostToGlasses]. [connect] — бесконечный цикл приёма с
* реконнектом через 3с при обрыве.
* WS-клиент очков: подключается к хосту (телефону). Работает **JSON-строками**
* (TASK-transport.md п.2 — протокол не в заботах транспорта): [connect]
* бесконечный цикл приёма с реконнектом через 3с при обрыве, [send] — строка
* (декод/кодинг вызывающий, т.е. HostConnection поверх [GlassesTransport]).
*/
class GlassesWsClient(
private val url: String, // ws://<host>:8080/ws/glasses
private val http: HttpClient = defaultWsClient(),
) {
) : GlassesTransport {
override val name = "wifi"
@Volatile
private var closed = false
@@ -36,13 +34,13 @@ class GlassesWsClient(
/**
* Подключиться и слушать. Возвращается при вызове [close].
* [onHostMessage] — входящие от хоста; [onConnected]/[onDisconnected] —
* [onMessage] — входящие JSON-строки от хоста; [onConnected]/[onDisconnected] —
* события (пере)подключения (для Hello и смены статуса в UI).
*/
suspend fun connect(
onHostMessage: suspend (HostToGlasses) -> Unit,
onConnected: suspend () -> Unit = {},
onDisconnected: suspend () -> Unit = {},
override suspend fun connect(
onMessage: suspend (String) -> Unit,
onConnected: suspend () -> Unit,
onDisconnected: suspend () -> Unit,
) {
while (!closed) {
try {
@@ -52,9 +50,7 @@ class GlassesWsClient(
try {
for (frame in ws.incoming) {
if (frame is Frame.Text) {
val text = frame.readText()
val msg = protocolJson.decodeFromString(HostToGlasses.serializer(), text)
onHostMessage(msg)
onMessage(frame.readText())
}
}
} finally {
@@ -72,14 +68,13 @@ class GlassesWsClient(
}
}
/** Отправить сообщение хосту. Без активного соединения — no-op. */
suspend fun send(msg: GlassesToHost) {
val text = protocolJson.encodeToString(GlassesToHost.serializer(), msg)
session?.send(text)
/** Отправить JSON-строку хосту. Без активного соединения — no-op. */
override suspend fun send(json: String) {
session?.send(json)
}
/** Закрыть клиент: выйти из цикла [connect] и закрыть активное соединение. */
fun close() {
override fun close() {
closed = true
session?.cancel()
}
@@ -0,0 +1,36 @@
package pw.binom.viewmate.core.net
import io.ktor.client.HttpClient
import pw.binom.viewmate.core.media.defaultWsClient
/**
* WiFi-транспорт, сторона очков («клиент») — TASK-transport.md п.2:
* [GlassesTransport] поверх [GlassesWsClient] — обёртка, логика цикла
* подключения/реконнекта не переписывается (переносится в интерфейс).
* Адрес (mDNS/scan/fallback — HostConnection) передаётся в URL.
*/
class WifiGlassesTransport(
url: String,
http: HttpClient = defaultWsClient(),
) : GlassesTransport {
override val name = "wifi"
private val client = GlassesWsClient(url, http)
override suspend fun connect(
onMessage: suspend (String) -> Unit,
onConnected: suspend () -> Unit,
onDisconnected: suspend () -> Unit,
) {
client.connect(onMessage, onConnected, onDisconnected)
}
override suspend fun send(json: String) {
client.send(json)
}
override fun close() {
client.close()
}
}
@@ -34,11 +34,20 @@ class GlassesWsClientTest {
companion object {
private const val PATH = "/ws/glasses"
private val json = protocolJson
private fun hostToGlasses(text: String): HostToGlasses =
json.decodeFromString(HostToGlasses.serializer(), text)
private fun glassesToHost(text: String): GlassesToHost =
json.decodeFromString(GlassesToHost.serializer(), text)
}
/** Локальный WS-сервер: Hello → Welcome, всё входящее собирает в список. */
/** Локальный WS-сервер: Hello → Welcome, всё входящее (JSON-строками) собирает в очередь. */
private class TestServer(private val port: Int = 0) {
val received = ConcurrentLinkedQueue<GlassesToHost>()
/** Входящие JSON-строки (транспорт — независим от протокола). */
val receivedRaw = ConcurrentLinkedQueue<String>()
private val sessions = ConcurrentLinkedQueue<DefaultWebSocketServerSession>()
private val server: EmbeddedServer<*, *> = embeddedServer(CIO, port = port, host = "127.0.0.1") {
@@ -54,10 +63,11 @@ class GlassesWsClientTest {
try {
for (frame in incoming) {
if (frame is Frame.Text) {
val msg = protocolJson.decodeFromString(GlassesToHost.serializer(), frame.readText())
received.add(msg)
val text = frame.readText()
receivedRaw.add(text)
val msg = glassesToHost(text)
if (msg is Hello) {
send(protocolJson.encodeToString(
send(json.encodeToString(
HostToGlasses.serializer(),
Welcome(mode = GlassesMode.MOVIE),
))
@@ -83,7 +93,7 @@ class GlassesWsClientTest {
get() = sessions.size
suspend fun broadcast(msg: HostToGlasses) {
val text = protocolJson.encodeToString(HostToGlasses.serializer(), msg)
val text = json.encodeToString(HostToGlasses.serializer(), msg)
for (s in sessions) {
try {
s.send(text)
@@ -114,20 +124,21 @@ class GlassesWsClientTest {
try {
runBlocking {
val client = GlassesWsClient(server.url())
val messages = mutableListOf<HostToGlasses>()
val messages = ConcurrentLinkedQueue<String>()
val connected = CompletableDeferred<Unit>()
val job = launch {
client.connect(
onHostMessage = { messages.add(it) },
onMessage = { messages.add(it) },
onConnected = { connected.complete(Unit) },
onDisconnected = {},
)
}
withTimeout(10_000) { connected.await() }
client.send(Hello(appVersion = "0.1.0"))
client.send(json.encodeToString(GlassesToHost.serializer(), Hello(appVersion = "0.1.0")))
awaitTrue { messages.any { it is Welcome } }
val welcome = messages.filterIsInstance<Welcome>().first()
awaitTrue { messages.any { hostToGlasses(it) is Welcome } }
val welcome = messages.first { hostToGlasses(it) is Welcome }.let { hostToGlasses(it) as Welcome }
assertEquals(GlassesMode.MOVIE, welcome.mode)
client.close()
@@ -147,16 +158,19 @@ class GlassesWsClientTest {
val connected = CompletableDeferred<Unit>()
val job = launch {
client.connect(
onHostMessage = {},
onMessage = {},
onConnected = { connected.complete(Unit) },
onDisconnected = {},
)
}
withTimeout(10_000) { connected.await() }
client.send(Gesture(gesture = "DoubleClick"))
client.send(json.encodeToString(GlassesToHost.serializer(), Gesture(gesture = "DoubleClick")))
awaitTrue { server.received.any { it is Gesture } }
val gesture = server.received.filterIsInstance<Gesture>().first()
awaitTrue { server.receivedRaw.any { glassesToHost(it) is Gesture } }
val gesture = server.receivedRaw.first { glassesToHost(it) is Gesture }.let {
glassesToHost(it) as Gesture
}
assertEquals("DoubleClick", gesture.gesture)
client.close()
@@ -173,12 +187,13 @@ class GlassesWsClientTest {
try {
runBlocking {
val client = GlassesWsClient(server.url())
val messages = mutableListOf<HostToGlasses>()
val messages = ConcurrentLinkedQueue<String>()
val connected = CompletableDeferred<Unit>()
val job = launch {
client.connect(
onHostMessage = { messages.add(it) },
onMessage = { messages.add(it) },
onConnected = { connected.complete(Unit) },
onDisconnected = {},
)
}
@@ -187,8 +202,8 @@ class GlassesWsClientTest {
server.broadcast(SetMode(GlassesMode.CHAT))
awaitTrue { messages.any { it is SetMode } }
val setMode = messages.filterIsInstance<SetMode>().first()
awaitTrue { messages.any { hostToGlasses(it) is SetMode } }
val setMode = messages.first { hostToGlasses(it) is SetMode }.let { hostToGlasses(it) as SetMode }
assertEquals(GlassesMode.CHAT, setMode.mode)
client.close()
@@ -199,6 +214,50 @@ class GlassesWsClientTest {
}
}
/**
* Проверка самого [GlassesTransport]: обмен — сырыми JSON-строками —
* и на стороне отправки (encode вызывающий), и на стороне приёма.
*/
@Test
fun transportApiExchangesRawJsonStrings() {
val server = TestServer()
try {
runBlocking {
val transport = WifiGlassesTransport(server.url())
val rawMessages = ConcurrentLinkedQueue<String>()
val connected = CompletableDeferred<Unit>()
val job = launch {
transport.connect(
onMessage = { rawMessages.add(it) },
onConnected = { connected.complete(Unit) },
onDisconnected = {},
)
}
withTimeout(10_000) { connected.await() }
assertEquals("wifi", transport.name)
transport.send(json.encodeToString(GlassesToHost.serializer(), Hello(appVersion = "0.3.0")))
awaitTrue { server.receivedRaw.any { it.contains("\"hello\"") } }
val helloJson = server.receivedRaw.first { it.contains("\"hello\"") }
val hello = glassesToHost(helloJson) as Hello
assertEquals("0.3.0", hello.appVersion)
// исходящее событие — тоже сырая строка; декод — за вызывающим
// (первый Welcome(MOVIE) — автоответ сервера на Hello; ждём наш CHAT)
server.broadcast(Welcome(mode = GlassesMode.CHAT))
awaitTrue { rawMessages.any { (hostToGlasses(it) as? Welcome)?.mode == GlassesMode.CHAT } }
val chatted = rawMessages.first { (hostToGlasses(it) as? Welcome)?.mode == GlassesMode.CHAT }
assertEquals(GlassesMode.CHAT, (hostToGlasses(chatted) as Welcome).mode)
transport.close()
job.cancelAndJoin()
}
} finally {
server.stop()
}
}
@Test
fun reconnectsAfterServerDrop() {
val server = TestServer()
@@ -209,8 +268,9 @@ class GlassesWsClientTest {
val client = GlassesWsClient(server.url())
val job = launch {
client.connect(
onHostMessage = {},
onMessage = {},
onConnected = { connects++ },
onDisconnected = {},
)
}