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
@@ -0,0 +1,7 @@
package pw.binom.viewmate.core.protocol
import kotlinx.serialization.Serializable
/** Состояния голосового ассистента (отображаются на панели очков). */
@Serializable
enum class AssistantState { IDLE, LISTENING, THINKING, SPEAKING }
@@ -0,0 +1,54 @@
package pw.binom.viewmate.core.protocol
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import pw.binom.viewmate.core.GlassesMode
import pw.binom.viewmate.core.MediaCommand
/**
* Сообщения от хоста (телефона) к очкам.
* Полиморфная сериализация: дискриминатор "type" со значением @SerialName.
*/
@Serializable
sealed interface HostToGlasses
/** Ответ на hello: текущее состояние. */
@Serializable
@SerialName("welcome")
data class Welcome(val mode: GlassesMode, val sessionId: String? = null) : HostToGlasses
/** Переключить режим очков. */
@Serializable
@SerialName("set_mode")
data class SetMode(val mode: GlassesMode) : HostToGlasses
/** Команда медиа (MediaCommand: PLAY/PAUSE/TOGGLE/SEEK_FORWARD/SEEK_BACKWARD/NEXT_AUDIO/PREV_AUDIO/CLOSE). */
@Serializable
@SerialName("media_command")
data class MediaCommandMsg(val command: MediaCommand, val seekMs: Long = 0) : HostToGlasses
/** Включить фильм: очки грузят videoUrl и играют, звук — на телефоне. */
@Serializable
@SerialName("play_video")
data class PlayVideo(
val itemId: String,
val title: String,
val videoUrl: String,
val audioIndex: Int = 0,
val startPositionMs: Long = 0,
) : HostToGlasses
/** Состояние ассистента (IDLE/LISTENING/THINKING/SPEAKING) + время записи для панели. */
@Serializable
@SerialName("assistant_state")
data class AssistantStateMsg(val state: AssistantState, val recordingSeconds: Int = 0) : HostToGlasses
/** Текст ответа ассистента (для показа по LongClick и скролла свайпами). */
@Serializable
@SerialName("show_text")
data class ShowText(val text: String, val sessionId: String? = null) : HostToGlasses
/** Сессия чата (переключение). */
@Serializable
@SerialName("chat_session")
data class ChatSessionMsg(val sessionId: String, val title: String) : HostToGlasses
@@ -0,0 +1,40 @@
package pw.binom.viewmate.core.protocol
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
/**
* Сообщения от очков к хосту (телефону).
* Полиморфная сериализация: дискриминатор "type" со значением @SerialName.
*/
@Serializable
sealed interface GlassesToHost
/** Жест с тачпада очков (SDK уже распознал: Click/DoubleClick/SlideBackward/SlideForward/LongClick/TripleClick). */
@Serializable
@SerialName("gesture")
data class Gesture(val gesture: String) : GlassesToHost
/** Периодический статус очков. */
@Serializable
@SerialName("glasses_status")
data class GlassesStatus(
val batteryPercent: Int,
val storageUsedGb: Double,
val storageTotalGb: Double,
) : GlassesToHost
/** Позиция воспроизведения (очки — мастер таймкода, шлют регулярно). */
@Serializable
@SerialName("playback_position")
data class PlaybackPosition(val positionMs: Long, val playing: Boolean) : GlassesToHost
/** Очки выключились / экран погас. */
@Serializable
@SerialName("glasses_off")
data class GlassesOff(val reason: String = "display_off") : GlassesToHost
/** Запрос хосту: дай каталог/статус (после подключения). */
@Serializable
@SerialName("hello")
data class Hello(val appVersion: String = "0.1.0") : GlassesToHost
@@ -0,0 +1,82 @@
package pw.binom.viewmate.core.protocol
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.jsonPrimitive
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertIs
import kotlin.test.assertNotNull
import kotlin.test.assertNull
import kotlin.test.assertTrue
class GlassesToHostTest {
private val json = Json {
classDiscriminator = "type"
}
@Test
fun gestureRoundTrip() {
val msg: GlassesToHost = Gesture(gesture = "DoubleClick")
val text = json.encodeToString(GlassesToHost.serializer(), msg)
assertEquals("""{"type":"gesture","gesture":"DoubleClick"}""", text)
val decoded = json.decodeFromString(GlassesToHost.serializer(), text)
assertIs<Gesture>(decoded)
assertEquals("DoubleClick", decoded.gesture)
}
@Test
fun glassesStatusRoundTrip() {
val msg: GlassesToHost = GlassesStatus(batteryPercent = 80, storageUsedGb = 12.5, storageTotalGb = 32.0)
val text = json.encodeToString(GlassesToHost.serializer(), msg)
val decoded = json.decodeFromString(GlassesToHost.serializer(), text)
assertIs<GlassesStatus>(decoded)
assertEquals(80, decoded.batteryPercent)
assertEquals(12.5, decoded.storageUsedGb)
assertEquals(32.0, decoded.storageTotalGb)
}
@Test
fun playbackPositionRoundTrip() {
val msg: GlassesToHost = PlaybackPosition(positionMs = 65_432, playing = true)
val text = json.encodeToString(GlassesToHost.serializer(), msg)
val decoded = json.decodeFromString(GlassesToHost.serializer(), text)
assertIs<PlaybackPosition>(decoded)
assertEquals(65_432, decoded.positionMs)
assertTrue(decoded.playing)
}
@Test
fun glassesOffRoundTripWithDefault() {
val msg: GlassesToHost = GlassesOff()
val text = json.encodeToString(GlassesToHost.serializer(), msg)
val decoded = json.decodeFromString(GlassesToHost.serializer(), text)
assertIs<GlassesOff>(decoded)
assertEquals("display_off", decoded.reason)
}
@Test
fun glassesOffCustomReason() {
val msg: GlassesToHost = GlassesOff(reason = "battery_empty")
val decoded = json.decodeFromString(GlassesToHost.serializer(), json.encodeToString(GlassesToHost.serializer(), msg))
assertIs<GlassesOff>(decoded)
assertEquals("battery_empty", decoded.reason)
}
@Test
fun helloRoundTrip() {
val msg: GlassesToHost = Hello()
val text = json.encodeToString(GlassesToHost.serializer(), msg)
val decoded = json.decodeFromString(GlassesToHost.serializer(), text)
assertIs<Hello>(decoded)
assertEquals("0.1.0", decoded.appVersion)
}
@Test
fun discriminatorIsType() {
val text = json.encodeToString(GlassesToHost.serializer(), Gesture(gesture = "Click"))
val obj = json.parseToJsonElement(text) as JsonObject
assertEquals("gesture", obj["type"]!!.jsonPrimitive.content)
}
}
@@ -0,0 +1,122 @@
package pw.binom.viewmate.core.protocol
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.jsonPrimitive
import pw.binom.viewmate.core.GlassesMode
import pw.binom.viewmate.core.MediaCommand
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertIs
import kotlin.test.assertNull
class HostToGlassesTest {
private val json = Json {
classDiscriminator = "type"
}
@Test
fun welcomeRoundTrip() {
val msg: HostToGlasses = Welcome(mode = GlassesMode.CHAT, sessionId = "s-42")
val text = json.encodeToString(HostToGlasses.serializer(), msg)
assertEquals("""{"type":"welcome","mode":"CHAT","sessionId":"s-42"}""", text)
val decoded = json.decodeFromString(HostToGlasses.serializer(), text)
assertIs<Welcome>(decoded)
assertEquals(GlassesMode.CHAT, decoded.mode)
assertEquals("s-42", decoded.sessionId)
}
@Test
fun welcomeNullSessionId() {
val msg: HostToGlasses = Welcome(mode = GlassesMode.MOVIE)
val decoded = json.decodeFromString(HostToGlasses.serializer(), json.encodeToString(HostToGlasses.serializer(), msg))
assertIs<Welcome>(decoded)
assertNull(decoded.sessionId)
}
@Test
fun setModeRoundTrip() {
val msg: HostToGlasses = SetMode(mode = GlassesMode.MOVIE)
val text = json.encodeToString(HostToGlasses.serializer(), msg)
val decoded = json.decodeFromString(HostToGlasses.serializer(), text)
assertIs<SetMode>(decoded)
assertEquals(GlassesMode.MOVIE, decoded.mode)
}
@Test
fun mediaCommandRoundTrip() {
val msg: HostToGlasses = MediaCommandMsg(command = MediaCommand.SEEK_FORWARD, seekMs = 30_000)
val text = json.encodeToString(HostToGlasses.serializer(), msg)
assertEquals("""{"type":"media_command","command":"SEEK_FORWARD","seekMs":30000}""", text)
val decoded = json.decodeFromString(HostToGlasses.serializer(), text)
assertIs<MediaCommandMsg>(decoded)
assertEquals(MediaCommand.SEEK_FORWARD, decoded.command)
assertEquals(30_000, decoded.seekMs)
}
@Test
fun mediaCommandDefaultSeek() {
val msg: HostToGlasses = MediaCommandMsg(command = MediaCommand.PAUSE)
val decoded = json.decodeFromString(HostToGlasses.serializer(), json.encodeToString(HostToGlasses.serializer(), msg))
assertIs<MediaCommandMsg>(decoded)
assertEquals(0, decoded.seekMs)
}
@Test
fun playVideoRoundTrip() {
val msg: HostToGlasses = PlayVideo(
itemId = "movie-1",
title = "Interstellar",
videoUrl = "https://media.example.com/ns.mp4",
audioIndex = 2,
startPositionMs = 123_456,
)
val text = json.encodeToString(HostToGlasses.serializer(), msg)
val decoded = json.decodeFromString(HostToGlasses.serializer(), text)
assertIs<PlayVideo>(decoded)
assertEquals("movie-1", decoded.itemId)
assertEquals("Interstellar", decoded.title)
assertEquals("https://media.example.com/ns.mp4", decoded.videoUrl)
assertEquals(2, decoded.audioIndex)
assertEquals(123_456, decoded.startPositionMs)
}
@Test
fun assistantStateRoundTrip() {
val msg: HostToGlasses = AssistantStateMsg(state = AssistantState.LISTENING, recordingSeconds = 7)
val text = json.encodeToString(HostToGlasses.serializer(), msg)
assertEquals("""{"type":"assistant_state","state":"LISTENING","recordingSeconds":7}""", text)
val decoded = json.decodeFromString(HostToGlasses.serializer(), text)
assertIs<AssistantStateMsg>(decoded)
assertEquals(AssistantState.LISTENING, decoded.state)
assertEquals(7, decoded.recordingSeconds)
}
@Test
fun showTextRoundTrip() {
val msg: HostToGlasses = ShowText(text = "Привет, мир", sessionId = "s-1")
val text = json.encodeToString(HostToGlasses.serializer(), msg)
val decoded = json.decodeFromString(HostToGlasses.serializer(), text)
assertIs<ShowText>(decoded)
assertEquals("Привет, мир", decoded.text)
assertEquals("s-1", decoded.sessionId)
}
@Test
fun chatSessionRoundTrip() {
val msg: HostToGlasses = ChatSessionMsg(sessionId = "ch-7", title = "Планы на выходные")
val text = json.encodeToString(HostToGlasses.serializer(), msg)
val decoded = json.decodeFromString(HostToGlasses.serializer(), text)
assertIs<ChatSessionMsg>(decoded)
assertEquals("ch-7", decoded.sessionId)
assertEquals("Планы на выходные", decoded.title)
}
@Test
fun discriminatorIsType() {
val text = json.encodeToString(HostToGlasses.serializer(), SetMode(GlassesMode.CHAT))
val obj = json.parseToJsonElement(text) as JsonObject
assertEquals("set_mode", obj["type"]!!.jsonPrimitive.content)
}
}