293 lines
14 KiB
Kotlin
293 lines
14 KiB
Kotlin
package pw.binom.viewmate.phone
|
||
|
||
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.log.LogEntry
|
||
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
|
||
import pw.binom.viewmate.core.protocol.GlassesOff
|
||
import pw.binom.viewmate.core.protocol.GlassesStatus
|
||
import pw.binom.viewmate.core.protocol.GlassesToHost
|
||
import pw.binom.viewmate.core.protocol.Gesture
|
||
import pw.binom.viewmate.core.protocol.Hello
|
||
import pw.binom.viewmate.core.protocol.HostToGlasses
|
||
import pw.binom.viewmate.core.protocol.LogBatchMsg
|
||
import pw.binom.viewmate.core.protocol.PlaybackPosition
|
||
import pw.binom.viewmate.core.protocol.SttAudio
|
||
import pw.binom.viewmate.core.protocol.SttCancel
|
||
import pw.binom.viewmate.core.protocol.SttDone
|
||
import pw.binom.viewmate.core.protocol.StopStt
|
||
import pw.binom.viewmate.core.protocol.Welcome
|
||
import pw.binom.viewmate.core.protocol.protocolJson
|
||
import pw.binom.viewmate.phone.stt.SttStreamer
|
||
import java.util.concurrent.ConcurrentHashMap
|
||
|
||
const val GLASSES_WS_PATH = "/ws/glasses"
|
||
|
||
/**
|
||
* Менеджер подключённых очков (телефон). Потокобезопасен (ConcurrentHashMap).
|
||
* Сессии адресуются **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,
|
||
) {
|
||
/** Сессии: connId → Unit. */
|
||
private val sessions = ConcurrentHashMap<String, Unit>()
|
||
|
||
private val _connected = MutableStateFlow(0)
|
||
val connected: StateFlow<Int> = _connected.asStateFlow()
|
||
|
||
private val _gesturesReceived = MutableStateFlow(0)
|
||
val gesturesReceived: StateFlow<Int> = _gesturesReceived.asStateFlow()
|
||
|
||
private val _batteryPercent = MutableStateFlow<Int?>(null)
|
||
val batteryPercent: StateFlow<Int?> = _batteryPercent.asStateFlow()
|
||
|
||
private val _storageUsedGb = MutableStateFlow<Double?>(null)
|
||
val storageUsedGb: StateFlow<Double?> = _storageUsedGb.asStateFlow()
|
||
|
||
private val _storageTotalGb = MutableStateFlow<Double?>(null)
|
||
val storageTotalGb: StateFlow<Double?> = _storageTotalGb.asStateFlow()
|
||
|
||
private val _storageTotalBytes = MutableStateFlow<Long?>(null)
|
||
val storageTotalBytes: StateFlow<Long?> = _storageTotalBytes.asStateFlow()
|
||
|
||
private val _storageFreeBytes = MutableStateFlow<Long?>(null)
|
||
val storageFreeBytes: StateFlow<Long?> = _storageFreeBytes.asStateFlow()
|
||
|
||
private val _mediaBytes = MutableStateFlow<Long?>(null)
|
||
val mediaBytes: StateFlow<Long?> = _mediaBytes.asStateFlow()
|
||
|
||
private val _appVersion = MutableStateFlow<String?>(null)
|
||
val appVersion: StateFlow<String?> = _appVersion.asStateFlow()
|
||
|
||
private val _mediaDir = MutableStateFlow<String?>(null)
|
||
val mediaDir: StateFlow<String?> = _mediaDir.asStateFlow()
|
||
|
||
private val _downloadedItemIds = MutableStateFlow<Set<String>>(emptySet())
|
||
/** itemId полностью скачанных на очки (из периодического GlassesStatus). */
|
||
val downloadedItemIds: StateFlow<Set<String>> = _downloadedItemIds.asStateFlow()
|
||
|
||
private val _glassesDownloads = MutableStateFlow<Map<String, Map<String, DownloadProgress>>>(emptyMap())
|
||
/** Прогресс скачивания на очки: itemId → имя файла → последний DownloadProgress от очков. */
|
||
val glassesDownloads: StateFlow<Map<String, Map<String, DownloadProgress>>> = _glassesDownloads.asStateFlow()
|
||
|
||
private val _positionMs = MutableStateFlow<Long?>(null)
|
||
val positionMs: StateFlow<Long?> = _positionMs.asStateFlow()
|
||
|
||
/**
|
||
* Хук на позицию очков: вызывается на каждый PlaybackPosition (и GlassesOff
|
||
* с playing=false). [itemId]+[audioIndex] — для автоподхвата звука после
|
||
* рестарта телефона (см. PhoneApp.autostartAudio).
|
||
*/
|
||
var onPlaybackPosition: ((positionMs: Long, playing: Boolean, itemId: String?, audioIndex: Int, sentTs: Long, durationMs: Long) -> Unit)? = null
|
||
|
||
/**
|
||
* Хук на лог-пачки очков: телефон прибавляет записи в свой лог-коллектор
|
||
* (BatchingLogCollector.recordBatch) — они уйдут на сервер вместе со своими.
|
||
*/
|
||
@Volatile
|
||
var onLogBatch: ((List<LogEntry>) -> Unit)? = null
|
||
|
||
/**
|
||
* Стриминговое распознавание речи с очков. Лениво создаётся через
|
||
* [sttFactory] (модели во filesDir/models); onSilence30s/onAutoFinished
|
||
* рассылаются через [broadcast].
|
||
*/
|
||
@Volatile
|
||
var stt: SttStreamer? = null
|
||
|
||
/**
|
||
* Единственный путь текста в LLM: вызывает PhoneApp (assistantChannel.send)
|
||
* на StopStt(cancel=false) с непустым текстом. Промежуточные VAD-фразы
|
||
* отсутствуют — только полный текст (по клику или по тишине 30 с).
|
||
*/
|
||
@Volatile
|
||
var onStopFullText: ((String) -> Unit)? = null
|
||
|
||
/** Фабрика SttStreamer на первый SttAudio (тяжёлая — создаётся один раз). */
|
||
@Volatile
|
||
var sttFactory: (() -> SttStreamer?)? = null
|
||
|
||
fun add(connId: String) {
|
||
sessions[connId] = Unit
|
||
_connected.value = sessions.size
|
||
log("hub", "очки подключились (connId $connId, всего: ${sessions.size})")
|
||
}
|
||
|
||
fun remove(connId: String) {
|
||
sessions.remove(connId)
|
||
_connected.value = sessions.size
|
||
log("hub", "очки отключились (connId $connId, всего: ${sessions.size})")
|
||
}
|
||
|
||
/** Отправить сообщение всем подключённым очкам (JSON-строкой через [transport]). */
|
||
suspend fun broadcast(msg: HostToGlasses) {
|
||
val text = protocolJson.encodeToString(HostToGlasses.serializer(), msg)
|
||
for (connId in sessions.keys) {
|
||
runCatching { transport.send(connId, text) }
|
||
.onFailure { log("hub", "не удалось отправить ${msg::class.simpleName} на $connId: ${it.message}") }
|
||
}
|
||
}
|
||
|
||
/** Обработать входящее текстовое сообщение от очков (JSON-строкой из [transport]). */
|
||
suspend fun handle(connId: String, text: String) {
|
||
val msg = try {
|
||
protocolJson.decodeFromString(GlassesToHost.serializer(), text)
|
||
} catch (e: Exception) {
|
||
log("hub", "ОШИБКА декодирования входящего: ${e::class.simpleName}: ${e.message} (текст: ${text.take(120)})")
|
||
return
|
||
}
|
||
when (msg) {
|
||
is Hello -> {
|
||
log("glasses", "hello (app=${msg.appVersion}, connId $connId) → welcome")
|
||
send(connId, Welcome(mode = state.mode, sessionId = state.activeSessionId))
|
||
}
|
||
|
||
is Gesture -> {
|
||
_gesturesReceived.value++
|
||
log("glasses", "жест с тачпада: ${msg.gesture}")
|
||
}
|
||
|
||
is GlassesStatus -> {
|
||
_batteryPercent.value = msg.batteryPercent
|
||
_storageUsedGb.value = msg.storageUsedGb
|
||
_storageTotalGb.value = msg.storageTotalGb
|
||
if (msg.storageTotalBytes > 0) _storageTotalBytes.value = msg.storageTotalBytes
|
||
if (msg.storageFreeBytes > 0) _storageFreeBytes.value = msg.storageFreeBytes
|
||
if (msg.mediaBytes > 0) _mediaBytes.value = msg.mediaBytes
|
||
if (msg.mediaDir.isNotBlank()) _mediaDir.value = msg.mediaDir
|
||
if (msg.appVersion.isNotBlank()) _appVersion.value = msg.appVersion
|
||
_downloadedItemIds.value = msg.downloadedItemIds.toSet()
|
||
log(
|
||
"glasses",
|
||
"статус: батарея ${msg.batteryPercent}%, память ${msg.storageUsedGb}/${msg.storageTotalGb} ГБ, " +
|
||
"медиа ${msg.mediaBytes} байт, скачано: ${msg.downloadedItemIds.size}, app=${msg.appVersion}",
|
||
)
|
||
}
|
||
|
||
is DownloadProgress -> {
|
||
val prev = _glassesDownloads.value[msg.itemId] ?: emptyMap()
|
||
val updated = _glassesDownloads.value + (msg.itemId to (prev + (msg.fileName to msg)))
|
||
_glassesDownloads.value = updated
|
||
log("glasses", "скачивание на очки: ${msg.itemId} ${msg.fileName} ${msg.percent}% (${msg.phase})")
|
||
}
|
||
|
||
is PlaybackPosition -> {
|
||
_positionMs.value = msg.positionMs
|
||
this.state.playing = msg.playing
|
||
val state = if (msg.playing) "играет" else "пауза"
|
||
log("glasses", "позиция: ${msg.positionMs} мс, $state, item=${msg.itemId} idx=${msg.audioIndex}")
|
||
onPlaybackPosition?.invoke(msg.positionMs, msg.playing, msg.itemId, msg.audioIndex, msg.sentTs, msg.durationMs)
|
||
}
|
||
|
||
is GlassesOff -> {
|
||
log("glasses", "очки выключились → пауза (${msg.reason})")
|
||
this.state.playing = false
|
||
onPlaybackPosition?.invoke(_positionMs.value ?: 0L, false, null, 0, 0L, -1L)
|
||
}
|
||
|
||
is LogBatchMsg -> {
|
||
log("glasses", "лог-пачка с очков: ${msg.entries.size} записей")
|
||
onLogBatch?.invoke(msg.entries)
|
||
}
|
||
|
||
is SttAudio -> {
|
||
val s = stt ?: sttFactory?.invoke()?.also { stt = it }
|
||
s?.accept(msg.data)
|
||
}
|
||
|
||
is StopStt -> {
|
||
if (msg.cancel) {
|
||
log("stt", "распознавание отменено (очки)")
|
||
send(connId, SttCancel(reason = SttCancel.REASON_CANCEL))
|
||
stt?.reset()
|
||
} else {
|
||
val full = stt?.finish() ?: ""
|
||
log("stt", "ВЕСЬ ТЕКСТ: $full")
|
||
stt?.reset()
|
||
if (full.isNotBlank()) {
|
||
send(connId, SttDone(full))
|
||
val phrase = full.trim()
|
||
log("stt", "фраза в LLM (по клику): $phrase")
|
||
onStopFullText?.invoke(phrase)
|
||
} else {
|
||
// Фразы не было — очки возвращаются в «слушаю» (SttCancel).
|
||
log("stt", "фразы не было — очки в «слушаю»")
|
||
broadcast(SttCancel(reason = SttCancel.REASON_USER))
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
private suspend fun send(connId: String, msg: HostToGlasses) {
|
||
val text = protocolJson.encodeToString(HostToGlasses.serializer(), msg)
|
||
runCatching { transport.send(connId, text) }
|
||
.onFailure { log("hub", "не удалось отправить ${msg::class.simpleName} на $connId: ${it.message}") }
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Сервер связи очков (запускается в 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,
|
||
) {
|
||
/** Активный транспорт: [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 val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||
private var acceptJob: Job? = null
|
||
|
||
/** Запустить приём подключений очков ([transport.accept] — бесконечный цикл). */
|
||
fun start() {
|
||
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) },
|
||
)
|
||
}
|
||
}
|
||
|
||
fun stop() {
|
||
transport.close()
|
||
scope.cancel()
|
||
}
|
||
}
|