feat: сетевая часть для app-phone (WS-сервер) и app-glasses (WS-клиент), PhoneState/PhoneActions → lib-core

lib-core:
- core/phone/: PhoneState, PhoneActions, GlassesSender (интерфейс) — перенесены из app-host
- core/net/: GlassesWsClient — KMP WS-клиент очков (реконнект, Hello, send GlassesToHost)
- GlassesWsClientTest + PhoneActionsTest (перенесён)

app-phone (Android):
- GlassesServer: Ktor WS-сервер (CIO) 0.0.0.0:8080/ws/glasses, принимает очки, Welcome из стейта
- PhoneApp (Application): клиенты, стейт, действия, сервер
- PhoneConfig: креды Jellyfin/Mirror
- MainActivity: статус сервера (порт, счётчик подключений)
- GlassesServerTest: hello→welcome, gesture

app-glasses (Android):
- GlassesApp (Application), HostConnection: WS-клиент к телефону, Hello, реконнект, обработка HostToGlasses (PlayVideo/SetMode и т.д.), StateFlow для UI
- MainActivity: статус подключения

app-host: PhoneActions/PhoneState удалены (теперь lib-core), адаптирован под GlassesSender

171 тест, 0 провалов; app-host жив (catalog/watch/status работают через lib-core)
This commit is contained in:
Hermes Agent
2026-08-11 15:06:07 +03:00
parent f0f1f71575
commit 298d8e0b69
27 changed files with 1165 additions and 118 deletions
@@ -0,0 +1,169 @@
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.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.encodeToString
import pw.binom.viewmate.core.GlassesMode
import pw.binom.viewmate.core.phone.GlassesSender
import pw.binom.viewmate.core.phone.PhoneState
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.PlaybackPosition
import pw.binom.viewmate.core.protocol.Welcome
import pw.binom.viewmate.core.protocol.protocolJson
import java.util.concurrent.ConcurrentHashMap
const val GLASSES_WS_PATH = "/ws/glasses"
/**
* Менеджер подключённых очков (телефон). Потокобезопасен (ConcurrentHashMap).
* Раздаёт исходящие сообщения всем очкам и обрабатывает входящие
* (Hello → Welcome, жесты/статус/позицию → лог, GlassesOff → пауза).
*/
class GlassesHub(
private val state: PhoneState,
) {
private val sessions = ConcurrentHashMap<DefaultWebSocketServerSession, Unit>()
private val _connected = MutableStateFlow(0)
val connected: StateFlow<Int> = _connected.asStateFlow()
private val _gesturesReceived = MutableStateFlow(0)
val gesturesReceived: StateFlow<Int> = _gesturesReceived.asStateFlow()
fun add(session: DefaultWebSocketServerSession) {
sessions[session] = Unit
_connected.value = sessions.size
log("hub", "очки подключились, всего: ${sessions.size}")
}
fun remove(session: DefaultWebSocketServerSession) {
sessions.remove(session)
_connected.value = sessions.size
log("hub", "очки отключились, всего: ${sessions.size}")
}
/** Отправить сообщение всем подключённым очкам. */
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}")
}
}
}
/** Обработать входящее текстовое сообщение от очков. */
suspend fun handle(session: DefaultWebSocketServerSession, text: String) {
val msg = protocolJson.decodeFromString(GlassesToHost.serializer(), text)
when (msg) {
is Hello -> {
log("glasses", "hello (app=${msg.appVersion}) → welcome")
send(session, Welcome(mode = state.mode, sessionId = state.activeSessionId))
}
is Gesture -> {
_gesturesReceived.value++
log("glasses", "жест с тачпада: ${msg.gesture}")
}
is GlassesStatus -> {
log("glasses", "статус: батарея ${msg.batteryPercent}%, память ${msg.storageUsedGb}/${msg.storageTotalGb} ГБ")
}
is PlaybackPosition -> {
val state = if (msg.playing) "играет" else "пауза"
log("glasses", "позиция: ${msg.positionMs} мс, $state")
}
is GlassesOff -> {
log("glasses", "очки выключились → пауза (${msg.reason})")
this.state.playing = false
}
}
}
private suspend fun send(session: DefaultWebSocketServerSession, msg: HostToGlasses) {
val text = protocolJson.encodeToString(HostToGlasses.serializer(), msg)
session.send(text)
}
}
/** Ktor-модуль телефона: WebSocket-роут для подключения очков. */
fun Application.glassesServerModule(hub: GlassesHub) {
install(WebSockets) {
pingPeriodMillis = 15_000
timeoutMillis = 15_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}")
} finally {
hub.remove(this)
}
}
}
}
/**
* WS-сервер телефона (CIO-движок — работает на Android, в отличие от Netty).
* Живёт в приложении (Application), слушает 0.0.0.0:[port]/ws/glasses.
*/
class GlassesServer(
private val port: Int = PhoneConfig.SERVER_PORT,
private val state: PhoneState = PhoneState(),
) {
val hub = GlassesHub(state)
/** GlassesSender-адаптер поверх хаба (для PhoneActions). */
val sender: GlassesSender = object : GlassesSender {
override suspend fun send(msg: HostToGlasses) = hub.broadcast(msg)
}
private var server: EmbeddedServer<*, *>? = null
fun start() {
if (server != null) return
server = embeddedServer(CIO, port = port, host = "0.0.0.0") {
glassesServerModule(hub)
}
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
}
}