feat: WS-протокол очки↔телефон + app-host (JVM Ktor-сервер)

lib-core (commonMain):
- Messages.kt: GlassesToHost (hello, gesture, glasses_status, playback_position, glasses_off)
- HostToGlasses.kt: Welcome, SetMode, MediaCommandMsg, PlayVideo, AssistantStateMsg, ShowText, ChatSessionMsg
- AssistantState enum (IDLE/LISTENING/THINKING/SPEAKING)
- round-trip тесты сериализации обоих направлений

app-host (переведён с Android на JVM):
- Ktor WS-сервер 0.0.0.0:8080/ws/glasses — очки подключаются как WS-клиенты
- GlassesHub: broadcast всем очкам, счётчик подключений (StateFlow)
- консольный ввод: play/mode/state/text/session/cmd/exit — команды от «телефона»
- логирование всех сообщений обоих направлений
- HostServerTest: hello→welcome, счётчик подключений

Проверено: сборка зелёная, 21 тест 0 провалов, живой WS-цикл (hello→welcome, gesture, статус, позиция, glasses_off)
This commit is contained in:
Hermes Agent
2026-08-11 12:35:30 +03:00
parent 5268a12d20
commit 9271517b3c
16 changed files with 751 additions and 66 deletions
-12
View File
@@ -1,12 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
<application
android:label="ViewMate Host"
android:theme="@style/Theme.AppCompat"
android:allowBackup="true">
</application>
</manifest>
@@ -0,0 +1,106 @@
package pw.binom.viewmate.host
import io.ktor.server.websocket.DefaultWebSocketServerSession
import io.ktor.websocket.send
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.serialization.json.Json
import pw.binom.viewmate.core.GlassesMode
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 java.util.concurrent.ConcurrentHashMap
/**
* Менеджер подключённых очков. Потокобезопасен (ConcurrentHashMap).
* Раздаёт исходящие сообщения всем очкам и обрабатывает входящие.
*/
class GlassesHub(private val json: Json) {
private val sessions = ConcurrentHashMap<DefaultWebSocketServerSession, Unit>()
private val _connected = MutableStateFlow(0)
val connected: StateFlow<Int> = _connected.asStateFlow()
private val _mode = MutableStateFlow(GlassesMode.MOVIE)
val mode: StateFlow<GlassesMode> = _mode.asStateFlow()
private val _gesturesReceived = MutableStateFlow(0)
val gesturesReceived: StateFlow<Int> = _gesturesReceived.asStateFlow()
/** Активная сессия чата (выставляется с «телефона»). */
@Volatile
var sessionId: String? = null
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 = json.encodeToString(HostToGlasses.serializer(), msg)
for (session in sessions.keys) {
try {
session.send(text)
log("host →", text)
} catch (e: Exception) {
log("hub", "не удалось отправить ${msg::class.simpleName}: ${e.message}")
}
}
}
/** Сменить режим очков и разослать SetMode. */
suspend fun setMode(mode: GlassesMode) {
_mode.value = mode
broadcast(pw.binom.viewmate.core.protocol.SetMode(mode))
}
/** Обработать входящее текстовое сообщение от очков. */
suspend fun handle(session: DefaultWebSocketServerSession, text: String) {
val msg = json.decodeFromString(GlassesToHost.serializer(), text)
when (msg) {
is Hello -> {
log("glasses", "hello (app=${msg.appVersion}) → welcome")
send(session, Welcome(mode = _mode.value, sessionId = sessionId))
}
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})")
}
}
}
private suspend fun send(session: DefaultWebSocketServerSession, msg: HostToGlasses) {
val text = json.encodeToString(HostToGlasses.serializer(), msg)
session.send(text)
log("host →", text)
}
}
@@ -0,0 +1,48 @@
package pw.binom.viewmate.host
import io.ktor.server.application.Application
import io.ktor.server.application.install
import io.ktor.server.routing.routing
import io.ktor.server.websocket.WebSockets
import io.ktor.server.websocket.webSocket
import io.ktor.websocket.Frame
import io.ktor.websocket.readText
import kotlinx.serialization.json.Json
/** Общий Json: полиморфизм через дискриминатор "type" (как описано в TASK.md). */
val hostJson: Json = Json {
classDiscriminator = "type"
ignoreUnknownKeys = true
encodeDefaults = true
}
const val GLASSES_WS_PATH = "/ws/glasses"
/** Ktor-модуль хоста: WebSocket-роут для подключения очков. */
fun Application.hostModule(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()
log("glasses →", text)
hub.handle(this, text)
}
}
} catch (e: Exception) {
log("server", "WS-ошибка: ${e.message}")
} finally {
hub.remove(this)
log("server", "соединение закрыто")
}
}
}
}
@@ -0,0 +1,13 @@
package pw.binom.viewmate.host
import java.time.Instant
import java.time.ZoneId
import java.time.format.DateTimeFormatter
private val fmt: DateTimeFormatter =
DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss.SSS").withZone(ZoneId.systemDefault())
/** Единый формат логов сервера: timestamp + тег + сообщение. */
fun log(tag: String, message: String) {
println("[${fmt.format(Instant.now())}] $tag $message")
}
@@ -0,0 +1,124 @@
package pw.binom.viewmate.host
import io.ktor.server.engine.EmbeddedServer
import io.ktor.server.engine.embeddedServer
import io.ktor.server.netty.Netty
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import pw.binom.viewmate.core.GlassesMode
import pw.binom.viewmate.core.MediaCommand
import pw.binom.viewmate.core.protocol.AssistantState
import pw.binom.viewmate.core.protocol.AssistantStateMsg
import pw.binom.viewmate.core.protocol.ChatSessionMsg
import pw.binom.viewmate.core.protocol.MediaCommandMsg
import pw.binom.viewmate.core.protocol.PlayVideo
import pw.binom.viewmate.core.protocol.ShowText
const val HOST_PORT = 8080
fun main() {
val hub = GlassesHub(hostJson)
val server = embeddedServer(Netty, port = HOST_PORT, host = "0.0.0.0") {
hostModule(hub)
}
server.start(wait = false)
log("server", "ViewMate Host слушает ws://0.0.0.0:$HOST_PORT$GLASSES_WS_PATH")
Runtime.getRuntime().addShutdownHook(Thread {
server.stop(1_000, 3_000)
log("server", "остановлен")
})
runBlocking {
launch {
hub.connected.collectLatest { count ->
log("hub", "подключено очков: $count")
}
}
consoleLoop(hub, server)
}
}
private suspend fun consoleLoop(hub: GlassesHub, server: EmbeddedServer<*, *>) {
printHelp()
while (true) {
val line = readlnOrNull() ?: break
val trimmed = line.trim()
if (trimmed.isEmpty()) continue
if (trimmed == "exit" || trimmed == "quit") {
log("console", "выход")
server.stop(1_000, 3_000)
break
}
when (trimmed) {
"help", "?" -> printHelp()
else -> handleConsoleCommand(hub, trimmed)
}
}
}
private suspend fun handleConsoleCommand(hub: GlassesHub, line: String) {
val parts = line.split(" ").filter { it.isNotBlank() }
val cmd = parts.firstOrNull() ?: return
when (cmd.lowercase()) {
"play" -> {
val url = parts.drop(1).joinToString(" ")
if (url.isEmpty()) return logUsage("play <url>")
hub.broadcast(PlayVideo(itemId = "manual", title = "Manual video", videoUrl = url))
}
"mode" -> {
val mode = parseEnum<GlassesMode>(parts.getOrNull(1))
if (mode == null) return logUsage("mode MOVIE|CHAT")
hub.setMode(mode)
}
"state" -> {
val state = parseEnum<AssistantState>(parts.getOrNull(1))
if (state == null) return logUsage("state IDLE|LISTENING|THINKING|SPEAKING [seconds]")
val seconds = parts.getOrNull(2)?.toIntOrNull() ?: 0
hub.broadcast(AssistantStateMsg(state = state, recordingSeconds = seconds))
}
"text" -> {
val text = parts.drop(1).joinToString(" ")
if (text.isEmpty()) return logUsage("text <текст>")
hub.broadcast(ShowText(text = text))
}
"session" -> {
val id = parts.getOrNull(1)
val title = parts.drop(2).joinToString(" ")
if (id == null || title.isEmpty()) return logUsage("session <id> <title>")
hub.sessionId = id
hub.broadcast(ChatSessionMsg(sessionId = id, title = title))
}
"cmd" -> {
val command = parseEnum<MediaCommand>(parts.getOrNull(1))
if (command == null) return logUsage("cmd PLAY|PAUSE|TOGGLE|SEEK_FORWARD|SEEK_BACKWARD|NEXT_AUDIO|PREV_AUDIO|CLOSE [seekMs]")
val seekMs = parts.getOrNull(2)?.toLongOrNull() ?: 0
hub.broadcast(MediaCommandMsg(command = command, seekMs = seekMs))
}
else -> log("console", "неизвестная команда «$cmd» (help — список команд)")
}
}
private inline fun <reified T : Enum<T>> parseEnum(value: String?): T? =
value?.let { runCatching { enumValueOf<T>(it.uppercase()) }.getOrNull() }
private fun logUsage(usage: String) = log("console", "usage: $usage")
private fun printHelp() {
log("console", "Команды (шлют всем подключённым очкам):")
log("console", " play <url> — PlayVideo (грузить и играть фильм)")
log("console", " mode MOVIE|CHAT — SetMode + сменить режим хоста")
log("console", " state <STATE> [sec] — AssistantState: IDLE|LISTENING|THINKING|SPEAKING")
log("console", " text <текст> — ShowText (ответ ассистента)")
log("console", " session <id> <title> — ChatSessionMsg (переключить сессию)")
log("console", " cmd <CMD> [seekMs] — MediaCommand: PLAY|PAUSE|TOGGLE|SEEK_FORWARD|SEEK_BACKWARD|NEXT_AUDIO|PREV_AUDIO|CLOSE")
log("console", " help — этот список")
log("console", " exit — остановить сервер")
}
@@ -1,12 +0,0 @@
package pw.binom.viewmate.host
import android.app.Activity
import android.os.Bundle
class MainActivity : Activity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
// Отладочный хост: к нему цепляются очки по WiFi, он эмулирует
// телефон (дубль-управление, логи, тест каналов). Пока — заглушка.
}
}